diff --git a/SEO-AUDIT.md b/SEO-AUDIT.md new file mode 100644 index 00000000..339dd923 --- /dev/null +++ b/SEO-AUDIT.md @@ -0,0 +1,1709 @@ +# Taskly — Technical & Content SEO Audit + +**Date:** 2026-07-30 +**Scope:** Read-only audit of this repository at `main` (`d459e9b9`). No code changed. +**Method:** Every claim below is traced to a file and line in this repo. Where a +claim depends on runtime state I could not read (production env vars, live +Search Console, live DB row counts), it is marked and listed in §7. + +--- + +## 0. Correcting the brief's premises before anything else + +Three assumptions in the audit brief are wrong against the code. They matter, +because two of them change what the priorities are. + +| Brief said | Code says | Reference | +|---|---|---| +| Domains are `taskly.ca` / `taskly.in` / `taskly.ai` | Domains are **`tasklyanything.ca` / `tasklyanything.in` / `tasklyanything.ai`**. `taskly.ca` is the *abandoned WordPress domain* | `lib/region/config.ts:50-54`; `app/llms.txt/route.ts:1-4` ("the abandoned taskly.ca domain") | +| Hero video "recently grew to ~8.3MB — an open regression" | **Already fixed.** `hero.mp4` (8,133,951 B) is orphaned — referenced nowhere. The live hero is `hero-seedance-dusk.mp4` (1.43 MB) / `hero-seedance.mp4` (2.14 MB), both `preload="none"`, played only after idle | `components/HeroPostTask.tsx:58,66-70`; grep for `hero.mp4` returns zero references outside the file listing | +| "~130 categories / 23 display buckets" | 130 marketplace categories (`lib/marketplace/taxonomy.ts`), **34** poster-facing category hints (`app/post-task/categories.tsx`), and **6** `/services/[bucket]` hubs (`lib/seo/sitemaps.ts:96`) | as cited | + +**And the headline correction:** this repo does *not* have the naive SEO problems +the brief anticipates. There is a deliberate, well-documented SEO layer +(`lib/seo/`, `lib/region/seo.ts`) that already gets per-host `metadataBase`, +per-host `robots.txt`, a sitemap index with per-type children, real `lastmod` +discipline, supply-gated anti-doorway programmatic pages, honest schema +guardrails, and bot-exempt geo redirects **right**. Sections 3.x say so +explicitly and move on. + +The real findings are narrower and mostly sit in one place: **the multi-domain +launch is half-flipped, and the per-market content/sitemap layer has not caught +up with the per-market URL layer.** + +--- + +## 1. Executive summary + +1. **The single most important unknown is the production value of five feature + flags** (`NEXT_PUBLIC_REGION_ROUTING_LIVE`, `MULTI_REGION_LIVE`, + `REGION_REDIRECTS`, `IN_MARKETPLACE_LIVE`, `INDEX_WIP_PAGES`). None are set in + `.env.local`. Which SEO failure you have *right now* depends entirely on them, + and they produce two different critical failures. Verify before doing anything + else (`lib/flags.ts:15,24,40,68,77`). +2. **If `MULTI_REGION_LIVE` is off (the default): three ccTLDs serve + byte-identical content, each self-canonicalising to itself, with no `hreflang` + emitted at all.** That is unconsolidated cross-domain duplication with zero + consolidation signal — the textbook cannibalisation case, and it is the + default state of the code (`lib/region/seo.ts:96`, `lib/flags.ts:24`). +3. **If `REGION_ROUTING_LIVE` is on: `tasklyanything.in`'s sitemap is broken.** + `/sitemap.xml` is reachable but all six child sitemaps it points at redirect to + `/waitlist`, because `alwaysReachable()` exempts `/sitemap.xml` and forgets + `/sitemaps/` (`lib/region/waitlist.ts:16-25`) — while the geo-router's + equivalent list *does* exempt it (`lib/region/geo-route.ts:79-83`). Two exempt + lists, one of them incomplete. +4. **`/hire/*` pages are not region-filtered and will duplicate the entire GTA + supply matrix onto the India domain.** `fetchOpenTasks()` region-filters + (`lib/seo/local-data.ts:210`); `fetchProviders()` and `fetchCompletedTasks()` + do not (`:147-187`). So `tasklyanything.in/hire/cleaning/toronto` passes the + supply gate on Canadian taskers and gets submitted in the `.in` sitemap. +5. **`staticSitemap()` is entirely region-blind** — `/seasonal` (snow removal), + `/cost/*` (CAD guides), `/compare/taskrabbit`, all 12 GTA city hubs get listed + under whichever host asks (`lib/seo/sitemaps.ts:98-112`, `:34-48`). +6. **Emails and SSR share links point at `https://taskly.ca`** — the dead + WordPress domain, not any of the three canonical hosts. It is the + `NEXT_PUBLIC_SITE_URL` fallback in 6+ modules (`.env.local:4`, + `lib/email/template.ts:64`, `lib/notifications/create.ts:26`, + `lib/site-url.ts:22`). Every transactional email leaks link equity off-brand. +7. **`Organization` schema hardcodes a Toronto postal address, GTA `areaServed`, + and `hello@taskly.ca` on all three domains** (`lib/seo/schema.ts:84-91`) — + even though the surrounding code carefully region-scopes the *description* + right above it (`:57-60`). The India entity claims a Canadian address. +8. **`` is hardcoded** (`app/layout.tsx:134`) despite + `REGIONS[r].locale` holding `en-CA` / `en-IN` / `en-US`. One-line fix, real + signal. +9. **14 marketing pages override the region-aware root title with hardcoded + "GTA"/"Toronto" copy.** On `.in`, the root layout titles the site "…in India" + and then `/pricing` says "Transparent pricing across the GTA" + (`app/pricing/page.tsx:9`). Full list in §3.3. +10. **The programmatic surface is built but not internally linked.** 72 possible + `/hire/{service}/{city}` pages; 4 are linked from the footer + (`components/Footer.tsx:69-72`), and the city-hub cross-link map covers only + 4 of 34 categories (`lib/seo/hire-pages.ts:65-70`). All 6 + `/services/[bucket]` hubs have **zero** inbound internal links. Sitemap-only + discovery is the weakest form of discovery there is. +11. **The entire task inventory is `noindex`** (`components/marketplace/PublicTaskView.tsx:88,118`). + That is a defensible policy call, but it means the marketplace's own long-tail + ("need a dresser assembled in Etobicoke") produces zero indexable pages; the + `/on/{city}` and `/on/{city}/{category}` hubs are the only surface capturing + it, and they are gated at ≥3 live tasks (`lib/seo/local-data.ts:25,252`). +12. **`PAGE_LASTMOD` is empty** (`lib/seo/sitemaps.ts:73-75`), so every static + page reports a frozen `2026-07-21`. The file's own comment says lastmod must + be truthful or Google ignores it — this will silently become untruthful. +13. **Homepage H1 is "Have your Sundays back"** — pure brand, no service or geo + token (`components/HeroPostTask.tsx:297`). Legitimate brand choice; note it is + a deliberate trade, not an oversight to "fix" blindly. +14. **Performance is in good shape and is not your ranking problem.** Video is + `preload="none"` + post-idle, LCP is a preloaded 81 KB WebP, GA is + `afterInteractive` + consent-gated, fonts are `next/font` with `display:swap`, + below-fold sections are `next/dynamic` with SSR retained + (`app/page.tsx:17-26,49`, `components/GoogleAnalytics.tsx:70-74`). +15. **Highest-ROI work is not technical.** The technical base is strong. The gap + is *content coverage*: 8 cost guides, 7 blog posts, 6 service hubs, and 4 + competitor pages against a 130-category taxonomy and a 12-city footprint. + +--- + +## 2. Severity table + +| ID | Sev | Phase | File:line | Impact | +|---|---|---|---|---| +| **C-1** | Critical | 2 | `lib/flags.ts:24`, `lib/region/seo.ts:96` | With `MULTI_REGION_LIVE` off, 3 ccTLDs serve identical content, self-canonical, **no hreflang** → unconsolidated duplication | +| **C-2** | Critical | 2 | `lib/region/waitlist.ts:16-25` | `/sitemaps/*.xml` not exempted from the India waitlist gate → all 6 child sitemaps 307 → `/waitlist`; `.in` sitemap index is dead | +| **C-3** | Critical | 2/5 | `lib/seo/local-data.ts:147-187` | `/hire/*` supply gate reads Canadian taskers/tasks with no region filter → GTA doorway pages indexable on `.in` | +| **H-1** | High | 2 | `lib/seo/sitemaps.ts:98-112`, `:34-48` | `staticSitemap()` + `HUB_CITIES` region-blind → `.in`/`.ai` sitemaps list Canada-only pages | +| **H-2** | High | 3 | `.env.local:4`, `lib/email/template.ts:64`, `lib/notifications/create.ts:26`, `lib/email/unsubscribe.ts:32`, `lib/site-url.ts:22` | `NEXT_PUBLIC_SITE_URL=https://taskly.ca` (dead domain) is the fallback origin for all email + SSR share links | +| **H-3** | High | 4 | `lib/seo/schema.ts:84-91` | `Organization` hardcodes Toronto `PostalAddress`, GTA `areaServed`, `hello@taskly.ca` on every host | +| **H-4** | High | 3 | `app/layout.tsx:134` | `` ignores `REGIONS[r].locale` (`en-CA`/`en-IN`) | +| **H-5** | High | 3 | 14 files, §3.3 | Page-level metadata hardcodes GTA/Toronto, overriding region-aware root metadata | +| **H-6** | High | 5 | `components/Footer.tsx:69-72`, `lib/seo/hire-pages.ts:65-70` | 68 of 72 `/hire` pages and all 6 `/services/[bucket]` hubs are internal-link orphans | +| **M-1** | Medium | 2 | `lib/seo/sitemaps.ts:73-75` | `PAGE_LASTMOD` empty → all static pages frozen at `2026-07-21` | +| **M-2** | Medium | 1/5 | `components/marketplace/PublicTaskView.tsx:88,118` | Entire task inventory `noindex` — long-tail demand queries have no landing surface | +| **M-3** | Medium | 5 | `app/services/page.tsx`, `app/instahelp/page.tsx`, `app/page.tsx` | `/`, `/services`, `/instahelp` are three overlapping "post anything / fixed price" landings, all indexable | +| **M-4** | Medium | 7 | `app/page.tsx:49` vs `components/HeroPostTask.tsx:59` | Preloads only the *dark* poster; light-theme users fetch a different, unpreloaded LCP image | +| **M-5** | Medium | 1 | `app/pro/[handle]/page.tsx` (`force-dynamic`) | Every tasker profile SSR'd per request, no ISR → TTFB + crawl budget at scale | +| **M-6** | Medium | 4 | `app/services/[bucket]/page.tsx`, `app/[province]/[city]/page.tsx` | No `ItemList`/`OfferCatalog` on listing pages that visibly render lists | +| **M-7** | Medium | 3 | `components/HeroPostTask.tsx:297` | Homepage H1 carries no service or geo token | +| **L-1** | Low | 2 | `app/snowsquad/page.tsx:7` | Permanent alias uses `redirect()` (307) not `permanentRedirect()` (308) | +| **L-2** | Low | 2 | `lib/region/waitlist.ts:16-25`, `lib/region/geo-route.ts:79-83` | `/llms.txt` exempted from neither list → redirects on `.in` | +| **L-3** | Low | 6 | `lib/seo/schema.ts:25-28` | `sameAs` contains one profile (LinkedIn) — thin entity graph for brand defence | +| **L-4** | Low | 7 | `public/assets/hero.mp4` (8.13 MB) | Orphaned 8 MB file in repo; `public/assets` is 114 MB total | + +--- + +## 3. Per-phase detailed findings + +### 3.1 Phase 1 — Surface area and rendering + +126 `page.tsx` files. Route groups behave as `CLAUDE.md` describes and I found no +drift. Breakdown: + +- **Public / indexable:** ~40 routes. +- **Auth-gated shells** (`(customer)/app/**`, `(provider)/provider/**`, + `(admin)/admin/**`): 63 routes, all `Disallow`ed in robots + (`app/robots.ts:14`). Correct. +- **Deliberately `noindex`:** `/u/[id]` (`app/u/[id]/page.tsx:89-90`), + `/posters` (campaign page, comment at `app/posters/page.tsx:23-26` documents the + founder call to avoid cannibalising `/`), task pages + (`components/marketplace/PublicTaskView.tsx:88,118`), `/waitlist`, + `/choose-country`, `/onboarding/*`, `/admin-verify`. + +**Rendering modes — verified from code, not inferred.** The pattern is +consistent and correct: pages that must be per-host dynamic read `headers()`, +pages that can stay static do. + +One structural consequence worth stating plainly: **the root layout calls +`headers()` in `generateMetadata` (`app/layout.tsx:77`) and again in the layout +body (`:127`), so every page in the app is dynamic.** The code knows this and +documents the intent — per-host caching is delegated to Cloudflare +(`app/layout.tsx:72-73`). That is a valid architecture, but it means **there is no +Next.js static or ISR HTML cache for any marketing page**; if the Cloudflare +layer is not actually configured per-host, every crawl hit is an origin render. +I cannot verify the CDN config from this repo — see §7. + +**Client-side-content risk (the "empty HTML for Googlebot" check).** This is the +part of the brief I expected to produce a long list. It produces a short one, +because the codebase consistently SSRs its content: + +| Route | Assessment | +|---|---| +| `/` | **Safe.** All sections are `next/dynamic` *with SSR retained* (`app/page.tsx:17-26`); `TaskComposer` is `dynamic(..., { ssr: true })` (`components/HeroPostTask.tsx:25-28`). Full copy in initial HTML. | +| `/browse-tasks` | **Safe.** First 30 tasks server-rendered before handing to the client (`app/browse-tasks/page.tsx:76-97`). | +| `/hire/*`, `/cost/*`, `/compare/*`, `/on/*`, `/services/[bucket]`, `/blog/*` | **Safe.** Server components, content in HTML. | +| `/post-task` | **Client wizard.** Indexable when `INDEX_WIP_PAGES` is on (`lib/flags.ts:15`, `lib/seo/sitemaps.ts:93`). Initial HTML is the shell, not the 130-category taxonomy. Low harm (it's a tool page, not a content page) but it will never rank for anything but "taskly post a task". | +| `/app/services/[category]`, `/app/browse-taskers` | Client-only — but inside the `Disallow`ed `/app/` tree. No SEO impact. | + +**Verdict: Phase 1 is in good shape.** No ranking losses from client-side +rendering. This is unusual and worth saying. + +### 3.2 Phase 2 — Crawl and indexing + +#### Done well (stated briefly, per instructions) + +- **`robots.txt` is generated per-host** with that host's own sitemap and `host` + directive (`app/robots.ts:42-54`). AI crawlers are explicitly allowed with an + articulated rationale (`:23-40`). Param facets are `Disallow`ed rather than + `noindex`ed — which is the correct control for faceted nav (`:16-21`). +- **Sitemap index + 6 typed children** so GSC reports per-page-class health + (`app/sitemap.xml/route.ts`, `app/sitemaps/[file]/route.ts`, + `lib/seo/sitemaps.ts:204`). Unknown child names `notFound()` (`:52-53`). +- **`lastmod` is real or absent; `changefreq`/`priority` dropped** — correct + (`lib/seo/sitemaps.ts:10-14`). +- **Sitemap↔robots agreement is actively enforced**: `/hire` and `/on` hubs are + listed only when their page-level indexability gate passes + (`lib/seo/sitemaps.ts:125-134,195-202`), WIP paths gate on the same flag the + pages do (`:90-93`), `/instahelp` gates on the same `UC_ENABLED` as its page + (`:86-88`), and `/join` is excluded because it redirects (`:80-81`). This is the + discipline most sites never get right. +- **`metadataBase` is per-host and derived from the *pure* host map, never the + gated resolver** (`app/layout.tsx:82,97`), with the regression war story + recorded at `:55-70`. `originFromHost` → `originFor` (`lib/region/seo.ts:29-36`). + Canonicals are **relative**, resolving against that per-host base + (`lib/region/seo.ts:92-98`), so no page can cross-canonicalise another domain. + This is exactly right. +- **`www` → apex 301 for all three ccTLDs** (`next.config.ts:108-133`). +- **Legacy WordPress URLs 301'd** (`next.config.ts:137-155`). +- **Trailing slash:** no `trailingSlash` key in `next.config.ts` → Next's default + `false`, which 308s `/path/` → `/path`. One canonical form. Correct. +- **Soft-404s are actively prevented.** `notFound()` is called inside + `generateMetadata` — *before* streaming — so the response carries a real 404 + status rather than a 200 with 404 UI (`app/[province]/[city]/page.tsx:48-52`, + and the same pattern in `[category]/page.tsx` and the task slug route). The + comment at `app/[province]/[city]/page.tsx:49-50` states the reasoning. A bad + city or province slug is a hard 404, and `resolveHubCity` requires the city to + belong to *that* province (`lib/seo/local-data.ts:67-71`). +- **Geo-redirect design is careful and correct.** Crawlers are excluded before + any market comparison (`lib/region/geo-route.ts:98`), signed-in users are never + moved (`:104`), stated preference is honoured (`:107`), permalinks are exempt to + break a documented redirect loop (`:36-66`), redirects are **302 never 301** + (`proxy.ts:256,302`; rationale at `lib/region/geo-route.ts:12-15`), and + `/robots.txt`, `/sitemap.xml`, `/sitemaps/` are exempt (`:79-83`). The + row-ownership redirect is also deliberately 302 with the reason stated + (`proxy.ts:205-207`). **No cloaking risk, no redirect chains, no crawler traps.** + +#### C-1 — Critical: no `hreflang` in the default flag state + +```ts +// lib/region/seo.ts:92-98 +export function marketingAlternates(path: string): NonNullable { + const p = normPath(path); + return { + canonical: p, + ...(multiRegionLive() ? { languages: hreflangLanguages(p) } : {}), // ← gate + }; +} +``` + +`multiRegionLive()` defaults to `false` (`lib/flags.ts:24-26`) and is unset in +`.env.local`. The gate's stated rationale is sound — don't point Google at +domains that don't resolve yet. But if all three domains **do** resolve today +(they are configured in `next.config.ts:108-133` with www→apex redirects, which +implies they are live), the current state is: + +- three ccTLDs, +- serving identical HTML (with `REGION_ROUTING_LIVE` off, `resolveRegion()` pins + every host to `'ca'` — `lib/region/config.ts:146`), +- each self-canonicalising to itself, +- with **no `hreflang` and no cross-canonical**. + +That is duplicate content across three domains with no consolidation signal at +all. Google will pick one arbitrarily and may suppress the others. + +**Fix — pick one, do not do both:** + +*Option A (preferred, if `.ca` and `.in` are meant to be indexed):* flip +`NEXT_PUBLIC_MULTI_REGION_LIVE=true`. The cluster is already correct and +reciprocal, and correctly excludes `.in` while it's a redirecting waitlist +(`lib/region/seo.ts:52-56`). **But do C-2, C-3 and H-1 first** or you will +advertise broken alternates. + +*Option B (if only one domain should rank for now):* leave `hreflang` off and +add a temporary cross-canonical from the secondary domains to the primary. This +is the opposite of what `lib/region/seo.ts:11-15` warns against, so it must be +explicitly temporary and reverted at launch: + +```ts +// lib/region/seo.ts — TEMPORARY, remove at multi-region launch +export function marketingAlternates(path: string): NonNullable { + const p = normPath(path); + if (!multiRegionLive()) { + // One market's content on three hosts → consolidate onto the primary until + // each domain has genuinely market-unique content. Revert with the flag. + return { canonical: `${originFor('ca')}${p}` }; + } + return { canonical: p, languages: hreflangLanguages(p) }; +} +``` + +#### C-2 — Critical: `.in` child sitemaps redirect to `/waitlist` + +Two exempt lists exist and only one is complete: + +```ts +// lib/region/geo-route.ts:79-83 — COMPLETE +pathname === '/robots.txt' || +pathname === '/sitemap.xml' || +pathname.startsWith('/sitemaps/') // ← present + +// lib/region/waitlist.ts:16-25 — INCOMPLETE +function alwaysReachable(pathname: string): boolean { + return ( + pathname === '/waitlist' || + pathname.startsWith('/auth') || + pathname === '/privacy' || + pathname === '/terms' || + pathname === '/robots.txt' || + pathname === '/sitemap.xml' // ← /sitemaps/ MISSING + ); +} +``` + +Consequence when `REGION_ROUTING_LIVE=true` + `IN_MARKETPLACE_LIVE=false` + +`REGION_REDIRECTS≠false`: `tasklyanything.in/robots.txt` advertises +`tasklyanything.in/sitemap.xml`; that index resolves and lists six children; all +six 307 to `/waitlist` (`proxy.ts:268-281`). GSC reports "Couldn't fetch" or +"Sitemap is HTML" for every child. The `.in` domain is effectively +unsubmittable. + +**Fix (one line):** + +```ts +// lib/region/waitlist.ts:16-25 +function alwaysReachable(pathname: string): boolean { + return ( + pathname === '/waitlist' || + pathname.startsWith('/auth') || + pathname === '/privacy' || + pathname === '/terms' || + pathname === '/robots.txt' || + pathname === '/sitemap.xml' || + pathname.startsWith('/sitemaps/') || // ← child sitemaps + pathname === '/llms.txt' // ← also fixes L-2 + ); +} +``` + +Better still: extract one shared `CRAWL_METADATA_PATHS` predicate and have both +`waitlist.ts` and `geo-route.ts` import it, so the lists cannot drift again. + +#### C-3 — Critical: `/hire` supply gate is region-blind + +The three data pulls in `lib/seo/local-data.ts` are inconsistent: + +```ts +// :201-211 — fetchOpenTasks — CORRECT +let query = admin.from('tasks').select(...).eq('status', 'open')...; +if (regionRoutingLive()) query = query.eq('region', await getRegion()); // ← filtered + +// :147-161 — fetchProviders — NO REGION FILTER +const { data } = await admin.from('provider_profiles') + .select(...).eq('status','approved').eq('published', true)...; + +// :163-187 — fetchCompletedTasks — NO REGION FILTER +admin.from('tasks').select(...).eq('status','completed')... +``` + +`getHireLocalData()` (`:301-341`) computes `indexable` purely from those two +unfiltered pulls (`:339`). So on `tasklyanything.in`: + +- `/hire/cleaning/toronto` matches Canadian approved taskers, +- `indexable === true`, +- `hireSitemap()` (`lib/seo/sitemaps.ts:125-134`) submits + `https://tasklyanything.in/hire/cleaning/toronto`. + +Up to **72 GTA supply pages duplicated onto the India ccTLD**, which is both +cross-market duplication *and* precisely the "city-swap template" the file's own +header (`:3-8`) exists to prevent. + +The city hubs self-heal (`fetchOpenTasks` is filtered → 0 India tasks → +`indexable: false` → dropped from `hubsSitemap`). `/hire` does not. + +**Fix:** + +```ts +// lib/seo/local-data.ts:147 — mirror the fetchOpenTasks pattern +const fetchProviders = cache(async (): Promise => { + try { + const admin = createServiceRoleClient(); + let q = admin.from('provider_profiles') + .select('handle, service_categories, task_skills, service_areas, rating_avg, ratings_count, jobs_completed, profiles!inner(full_name, avatar_url)') + .eq('status', 'approved').eq('published', true).not('handle', 'is', null).limit(1000); + if (regionRoutingLive()) q = q.eq('region', await getRegion()); // ← add + const { data } = await q; + return (data ?? []) as unknown as ProviderRow[]; + } catch { return []; } +}); +``` + +…and the same on `fetchCompletedTasks` (`:163`). **Verify `provider_profiles` and +`tasks` both actually carry a `region` column in production first** — migration +155 adds it and I cannot confirm it is applied (see §7). + +A belt-and-braces guard, since `/hire` and `/on` are Ontario-only concepts +today, is to gate the whole family on market: + +```ts +// lib/seo/sitemaps.ts — hireSitemap / hubsSitemap +export async function hireSitemap(origin: string, region: Region): Promise { + if (region !== 'ca') return []; // GTA-only page family + ... +} +``` + +#### H-1 — High: `staticSitemap()` is region-blind + +```ts +// lib/seo/sitemaps.ts:98-112 — no region parameter at all +export function staticSitemap(origin: string): SitemapUrl[] { + const paths = [ + ...STATIC_PATHS, // '/cost', '/reviews', … + ...(indexWipPages() ? WIP_PATHS : []), + ...(UC_ENABLED ? UC_PATHS : []), + ...CATEGORY_IDS.map((id) => `/book/${id}/details`), + ...COMPARE_SLUGS.map((s) => `/compare/${s}`), // taskrabbit, jiffy, kijiji… + ...SERVICE_BUCKETS.map((b) => `/services/${b}`), + '/seasonal', '/seasonal/taskers', // ← snow removal, on .in + ]; + return paths.map((p) => ({ loc: `${origin}${p}`, lastmod: PAGE_LASTMOD[p] ?? SITE_LASTMOD })); +} +``` + +The route handler already computes `hostRegion` and passes it to `blogSitemap` +(`app/sitemaps/[file]/route.ts:34,43`) — the plumbing exists, `staticSitemap` just +doesn't take it. Note `/about` is *already* region-gated at the page level +(`lib/region/gated-pages.ts:24`) yet is still listed in `STATIC_PATHS` for every +host (`lib/seo/sitemaps.ts:81`) — a sitemap↔page contradiction that exists +today, independent of any flag. + +**Fix:** + +```ts +// lib/seo/sitemaps.ts +const CA_ONLY_PATHS = ['/seasonal', '/seasonal/taskers', '/cost', '/about']; +const CA_ONLY_PREFIXES = ['/compare/', '/hire/', '/book/']; + +export function staticSitemap(origin: string, region: Region): SitemapUrl[] { + const paths = [ /* …as today… */ ] + .filter((p) => + region === 'ca' || + (!CA_ONLY_PATHS.includes(p) && !CA_ONLY_PREFIXES.some((x) => p.startsWith(x))), + ) + // and honour the page-visibility map so /about can never contradict its page + .filter((p) => pathAllowedInRegion(p, region)); + return paths.map((p) => ({ loc: `${origin}${p}`, lastmod: PAGE_LASTMOD[p] ?? SITE_LASTMOD })); +} +``` + +`HUB_CITIES` (`lib/seo/local-data.ts:34-48`) should likewise carry a `region` +field rather than relying on the task-count gate to hide non-CA cities. + +#### M-1 — `lastmod` will go stale + +```ts +// lib/seo/sitemaps.ts:70-75 +const SITE_LASTMOD = '2026-07-21'; +const PAGE_LASTMOD: Record = { + // (none yet — add '/pricing': 'YYYY-MM-DD' style entries as pages evolve) +}; +``` + +Every static page reports `2026-07-21`. The module's own rule (`:9-14`) is that +lastmod must be truthful or Google discounts it. A manually-maintained map will +not stay truthful. Prefer deriving it from git: + +```ts +// scripts/gen-page-lastmod.mjs — run in CI, writes lib/seo/page-lastmod.json +// git log -1 --format=%cs -- app/pricing/page.tsx → "2026-07-21" +``` + +#### Other Phase-2 notes + +- **`noindex` leak audit: clean.** No `X-Robots-Tag` anywhere; no env-gated + `noindex` that could strand a production route. Every `robots: { index: false }` + I found is deliberate and commented. +- **L-1:** `app/snowsquad/page.tsx:7` uses `redirect()` → 307. For a permanent + shareable alias, `permanentRedirect()` (308) passes signal to + `/seasonal/taskers`. +- **`/tasks/{uuid}` legacy URLs** render the task rather than redirecting, with a + canonical to the pretty URL (`components/marketplace/PublicTaskView.tsx:74,103,108`). + Since both are `noindex`, this is harmless; the stated reason (reliable social + unfurls) is sound. + +### 3.3 Phase 3 — Metadata layer + +#### Done well + +- Root layout metadata is fully region-aware — title country, description + locality, OG locale, OG url, all from the pure host map + (`app/layout.tsx:76-114`), with the reasoning and the 2026-07-21 regression + documented at `:55-75`. +- `title.template: '%s | Taskly'` with a per-region `default` (`:88-90`). +- Per-domain OG image generation via `ImageResponse`, region-aware — the card + footer shows the serving domain and market (`app/opengraph-image.tsx:16-20`). + Route-specific OG images exist for `/about`, `/pro/[handle]`, `/reviews/[slug]`, + `/tasks/[id]`. +- Twitter `summary_large_image` set globally (`app/layout.tsx:107-112`). +- Dynamic-route titles are genuinely keyword-bearing, not templated mush. + +**Actual rendered examples** (from the code, not invented): + +| Route | Template | Rendered | +|---|---|---| +| `/[province]/[city]` | `` `Open tasks in ${name} — live from the Taskly board` `` (`app/[province]/[city]/page.tsx:56`) | "Open tasks in Mississauga — live from the Taskly board \| Taskly" (63 + suffix — **over budget**, see below) | +| `/[province]/[city]/[category]` | `` `${label} tasks in ${cityName} — live open tasks` `` (`[category]/page.tsx:46`) | "Cleaning tasks in North York — live open tasks \| Taskly" | +| `/` on `.ca` | `` `Taskly — Get anything done in ${c.seoCountry}` `` (`app/layout.tsx:84`) | "Taskly — Get anything done in Canada" (36) ✅ | +| `/` on `.in` | same | "Taskly — Get anything done in India" (35) ✅ | +| `/` on `.ai` | `seoCountry === null` branch (`:85`) | "Taskly — Get anything done" (27) ✅ | + +**Title-length issue:** the `| Taskly` template adds 9 characters that several +dynamic titles don't budget for. "Open tasks in Richmond Hill — live from the +Taskly board | Taskly" is 68 characters and will truncate — and it truncates the +*brand*, and repeats "Taskly" twice. Either use +`title: { absolute: ... }` on these routes (as `/services` already does — +`app/services/page.tsx:28`) or shorten to +`` `Open tasks in ${name} — Taskly` ``. + +#### H-4 — `` is hardcoded + +```tsx +// app/layout.tsx:134 + +``` + +`REGIONS[r].locale` already holds `en-CA` / `en-IN` / `en-US` +(`lib/region/config.ts:50-54`) and `region` is already computed in the body at +`:128`. Fix: + +```tsx +const region = regionFromHost(h.get('x-forwarded-host') ?? h.get('host')); +const schemaOrigin = originFromHost(h.get('x-forwarded-host') ?? h.get('host')); +return +``` + +#### H-5 — Page metadata overrides the region-aware root with GTA copy + +14 pages hardcode a Canadian locality in metadata that is served on all three +hosts: + +`app/about/page.tsx` · `app/blog/page.tsx` · `app/browse-tasks/page.tsx:22` · +`app/contact/page.tsx:41` · `app/cost/page.tsx` · `app/how-it-works/page.tsx` · +`app/instahelp/page.tsx` · `app/post-task/page.tsx` · `app/price-guide/page.tsx` · +`app/pricing/page.tsx:9` · `app/reviews/page.tsx` · `app/services/page.tsx:28-30` · +`app/tasker-guide/page.tsx` · `app/taskers/page.tsx` + +Concrete: on `tasklyanything.in`, the site title says "…in India" and `/pricing` +then says *"Transparent pricing across the GTA."* + +`regionContent()` already exists for exactly this (`lib/region/content.ts:67-69`) +and is already used by the root layout, the OG image, and `llms.txt`. These pages +just predate it. Because they'd need `headers()`, wrap it: + +```ts +// lib/region/seo.ts — add +export async function marketingMetadata(path: string, build: (c: RegionContent) => Metadata): Promise { + const h = await headers(); + const region = regionFromHost(h.get('x-forwarded-host') ?? h.get('host')); + return { ...build(regionContent(region)), alternates: marketingAlternates(path) }; +} + +// app/pricing/page.tsx +export const generateMetadata = () => marketingMetadata('/pricing', (c) => ({ + title: 'Pricing — simple, honest, held by Taskly', + description: `Transparent pricing across ${c.metaLocation}. Free to post and compare offers; agree a fixed price held by Taskly and released only when the job's done. Taskers keep ~80% + tips.`, +})); +``` + +Alternatively — and cheaper — apply `pathAllowedInRegion` +(`lib/region/gated-pages.ts:41-49`) to keep GTA-specific pages off `.in` +entirely, which is what `/about` already does. + +#### Heading hierarchy + +Spot-checked, not exhaustive (see §7). Marketing pages carry exactly one H1 +(`components/HeroPostTask.tsx:297`, `app/landing-2/sections.tsx:104`, +`components/Pricing.tsx:54`, and the `

` in each programmatic template). No +logo-as-H1. + +> **Correction (2026-07-30):** an earlier revision of this audit reported that +> `/services` ships two H1s. **That was wrong** and the item has been removed +> from the roadmap. There are two different components named `Pricing`: +> `components/Pricing.tsx:54` (has an `

`, imported *only* by +> `app/pricing/page.tsx:2`) and the `Pricing` exported from +> `app/landing-2/sections.tsx` (no `

`), which is what `/services` actually +> imports (`app/services/page.tsx:8-23`). Verified: `sections.tsx` contains +> exactly one `

`, in `Hero()` at line 104. `/services` has one H1. + +Several app-shell pages have 2–5 `

` (`/login` 5, `/onboarding` 2, +`/onboarding/fix` 3) — all inside `Disallow`ed trees, so cosmetic only. + +**M-7:** the homepage H1, "Have your Sundays back", is a brand line with no +service or geo token. That is a real trade — the brand voice doc owns it — but it +means the strongest heading signal on the highest-authority page carries no +keyword. The subhead below it is where any "get anything done in Toronto" token +would have to live; verify it does. + +#### Image SEO + +- 28 files use `next/image`; 32 raw `` remain, and **every one has an `alt` + attribute** (grep for `` with `fetchPriority="high"`, + explicit `width`/`height`, and `decoding="async"` + (`components/HeroPostTask.tsx:262-271`) — correct, and the comment explains + why it bypasses the optimizer. +- Decorative images correctly use `alt=""`. +- Filenames are descriptive (`hero-seedance-dusk-poster.webp`, `CLEANING.jpg`). + +### 3.4 Phase 4 — Structured data + +#### Done well + +The honesty guardrail at `lib/seo/schema.ts:5-7` is the single best thing in this +audit. It is enforced, not aspirational: + +- `Organization` has **no `aggregateRating`** with an explicit comment saying why + (`:93`). +- `SearchAction` was **removed** because Google retired the sitelinks search box + in Nov 2024 (`:97-101`) — most sites still ship this inert markup. +- `taskerProfileSchema` marks up reviews **only when at least one real review + exists**, only reviews **visibly rendered on the page**, capped at 10, and + **never attached to Taskly-the-Organization** (`:167-221`). The self-serving- + review policy analysis at `:170-178` is correct: reviews of a *tasker* hosted on + Taskly are third-party-entity reviews (the Yelp/Thumbtack pattern) and are + eligible for stars. + +**This is a manual-action risk that has been correctly avoided.** Do not +"improve" it by adding sitewide ratings. + +**Current coverage:** + +| Type | Where | Status | +|---|---|---| +| `Organization` + `WebSite` | Every page via root layout (`app/layout.tsx:141`), per-host `@id` | ✅ | +| `BreadcrumbList` | `/blog`, `/blog/[slug]`, `/book/[category]/details`, `/compare/*`, `/cost`, `/cost/[service]`, `/hire/*`, `/how-it-works`, `/reviews`, `/reviews/[slug]`, `/seasonal`, `/seasonal/taskers`, `/on/{city}`, `/on/{city}/{cat}`, `/services/[bucket]` | ✅ with matching **visible** breadcrumbs (`components/Breadcrumbs.tsx`) on 7 routes | +| `Service` (+ `areaServed`) | `/hire/*:291`, `/book/[category]/details:135`, `/seasonal:257` | ✅ | +| `FAQPage` | `/blog/[slug]:94` (conditional on real FAQs), `/compare/*:179`, `/cost/[service]:51`, `/hire/*:304`, `/how-it-works:75`, `/seasonal:263`, `/seasonal/taskers:103` | ✅ all backed by on-page content | +| `LocalBusiness` + `AggregateRating` + `Review` | `/pro/[handle]:119`, conditional | ✅ | +| `ItemList` | `/cost:42` only | ⚠️ gap | + +#### H-3 — `Organization` claims a Canadian address on every domain + +```ts +// lib/seo/schema.ts:84-91 +email: 'hello@taskly.ca', // ← domain not in the region set +address: { + '@type': 'PostalAddress', + addressLocality: 'Toronto', addressRegion: 'ON', addressCountry: 'CA', +}, +areaServed: BRAND.areaServedCities.map((name) => ({ '@type': 'City', name })), // GTA ×15 +``` + +The function immediately above (`entityDescription`, `:57-60`) goes to real +trouble to keep `.in`/`.ai` location-neutral "so the India-facing entity never +implies Taskly is a Toronto business" — and then the very next function hardcodes +a Toronto postal address and 15 GTA cities for all three. The comment at `:68-69` +acknowledges this ("they're true of the Canadian company on every domain"), but +for entity-resolution purposes a `.in` `Organization` node asserting +`addressCountry: 'CA'` and `areaServed: [Toronto, Mississauga, …]` is the wrong +signal for India. + +Separately, `hello@taskly.ca` is a **fourth domain** appearing in the canonical +entity block. Support elsewhere uses `care@taskly.ca` +(`lib/support/knowledge.ts:20`). Neither is `tasklyanything.*`. For NAP +consistency the schema `email` must match whatever the Google Business Profile +and site footer say. + +**Fix:** + +```ts +export function organizationSchema(origin: string = SITE_URL) { + const region = regionFromOrigin(origin); + const isCA = region === 'ca'; + return { + '@context': 'https://schema.org', '@type': 'Organization', + '@id': `${origin}/#organization`, + name: BRAND.name, legalName: BRAND.legalName, url: origin, + logo: `${origin}/taskly-logo.svg`, image: `${origin}/opengraph-image`, + description: entityDescription(region), + slogan: BRAND.slogan, + telephone: BRAND.telephone, + email: BRAND.email, // single NAP-consistent value + ...(isCA ? { + address: { '@type': 'PostalAddress', addressLocality: 'Toronto', addressRegion: 'ON', addressCountry: 'CA' }, + areaServed: BRAND.areaServedCities.map((name) => ({ '@type': 'City', name })), + } : { + address: { '@type': 'PostalAddress', addressCountry: region === 'in' ? 'IN' : 'CA' }, + ...(region === 'in' ? { areaServed: { '@type': 'Country', name: 'India' } } : {}), + }), + sameAs: BRAND.sameAs, + }; +} +``` + +`serviceSchema` (`:116-138`) has the same issue — it attaches all 15 GTA cities +to every `Service` regardless of host. Since `/hire`, `/book`, `/seasonal` should +be `.ca`-only anyway (H-1), fixing the sitemap makes this moot in practice, but +the function should take `areaServed` from the caller rather than a constant. + +#### M-6 — Missing `ItemList` on pages that visibly render lists + +`/services/[bucket]`, `/[province]/[city]`, `/[province]/[city]/[category]`, and +`/reviews` all render lists in HTML but emit only `BreadcrumbList`. `ItemList` is +not a rich result, but it is a strong extraction signal for AI Overviews / +ChatGPT / Perplexity — which `app/robots.ts:9-13` shows is an explicit strategy. + +```ts +// lib/seo/schema.ts — add +export function itemListSchema(opts: { + name: string; + url: string; + items: Array<{ name: string; url: string }>; +}) { + return { + '@context': 'https://schema.org', '@type': 'ItemList', + name: opts.name, url: opts.url, + numberOfItems: opts.items.length, + itemListElement: opts.items.map((it, i) => ({ + '@type': 'ListItem', position: i + 1, name: it.name, url: it.url, + })), + }; +} +``` + +Apply on `/[province]/[city]` over the category chips it already renders +(`app/[province]/[city]/page.tsx` uses `hub.categories`), and on +`/services/[bucket]` over its service tiles. **Only over items visibly rendered +on that page** — same rule as the review markup. + +#### Trust signals currently prose-only + +The brief asks about surfacing CPIC background checks / $9M liability insurance / +fixed pricing as markup. **Do not.** `lib/seo/schema.ts:5-7` and +`business/Final business plan.md` §7 (per `CLAUDE.md`) set a hard boundary +against claiming CPIC/insurance/guarantees, and `app/robots.ts` / +`lib/seo/schema.ts:20` show the entity statement was written to stay inside it. +Marking up an insurance claim that isn't contractually in force is a +misrepresentation risk far worse than the SEO upside. + +What *is* safely markup-able, because it is mechanically true of the product: + +```jsonc +// on /how-it-works and /pricing — Offer with a real price specification +{ + "@context": "https://schema.org", + "@type": "Offer", + "offeredBy": { "@id": "https://tasklyanything.ca/#organization" }, + "priceSpecification": { + "@type": "PriceSpecification", + "priceCurrency": "CAD", + "description": "Free to post. Taskers keep ~80% of the agreed price; Taskly's 20% commission is HST-inclusive." + }, + "areaServed": { "@type": "City", "name": "Toronto" } +} +``` + +The 20% / ~80% split is code-verifiable (`lib/marketplace/fees.ts` +`COMMISSION_RATE = 0.2`), so it clears the honesty bar. Escrow ("money held by +Taskly, released when the job is done") likewise — it's the actual booking state +machine. + +### 3.5 Phase 5 — Information architecture and programmatic SEO + +#### Current URL taxonomy — better than the brief assumes + +There **is** a clean, indexable, non-query-param taxonomy: + +``` +/services marketing hub +/services/{bucket} 6 buckets: ai-and-tech, celebrations, newcomer, + wellness, home-and-life, tutoring +/hire/{service}/{city} 6 × 12 = 72 supply pages (evergreen "hire a X in Y") +/{province}/{city} 12 city hubs (live demand board) +/{province}/{city}/{category} category × city hubs (live demand board) +/{province}/{city}/{cat}/{slug} task permalinks (noindex) +/cost cost-guide hub +/cost/{service} 8 cost guides +/compare/{competitor} 4: taskrabbit, jiffy, kijiji, thumbtack +/blog, /blog/{slug} 7 posts + CMS +/pro/{handle} tasker profiles +/reviews, /reviews/{slug} published experiences +``` + +Category browsing is **not** behind query params or modals. The `/hire` (supply) +vs `/on` (demand) split is deliberate and well-reasoned +(`lib/seo/hire-pages.ts:51-63`) — distinct intent, distinct titles, each +independently gated, and cross-linked so Google reads them as complementary +rather than competing. That is a genuinely sophisticated architecture. + +**The anti-doorway discipline is the strongest part of this codebase.** A +`/hire/{s}/{c}` page indexes only at ≥3 matching taskers or ≥5 completed tasks +(`lib/seo/local-data.ts:22-23,339`); an `/on/{city}` hub only at ≥3 live open +tasks (`:25,252`). Below the gate the page still renders with an honest +zero-state but ships `noindex` and drops out of the sitemap. This is the correct +answer to "when does a service×city combo earn a page" and it is already +implemented — the brief asks me to design this rule and the codebase already has +it. + +#### H-6 — The architecture exists; the internal link graph doesn't + +I built the link graph from the code (script over `app/`, `components/`, `lib/` +matching both `href="…"` and `href:` object forms, plus template-literal +construction). Results: + +| Page family | Pages | Inbound internal links | +|---|---|---| +| `/hire/{service}/{city}` | up to 72 | **4** hardcoded in `components/Footer.tsx:69-72` (cleaning/toronto, handyman/toronto, movers/mississauga, tutoring/markham), plus `hireHrefForOnCategory` from city hubs | +| `/services/{bucket}` | 6 | **0** from `/services`. `app/services/page.tsx:38-52` renders the landing-2 sections, none of which link to the bucket hubs. Only `lib/blog/posts.ts` and `lib/seo/cost-guides.ts` reference two of them. | +| `/compare/{competitor}` | 4 | cross-linked among themselves (`app/compare/[competitor]/page.tsx`), **0** from any hub | +| `/cost/{service}` | 8 | ✅ `/cost` hub links all dynamically (`app/cost/page.tsx`) | +| `/{province}/{city}` | 12 | referenced only from `lib/blog/posts.ts` + `lib/seo/cost-guides.ts` prose | +| `/posters` | 1 | 0 — but deliberately `noindex` (`app/posters/page.tsx:23-26`). Correct. | + +`hireHrefForOnCategory` (`lib/seo/hire-pages.ts:65-70`) maps **4 of 34** +categories (`cleaning`, `handyman`, `moving-and-delivery`, +`tutoring-and-lessons`) — so `mehndi` and `snow-removal` hire pages have +essentially no internal path at all. + +**Net: ~68 of 72 `/hire` pages and all 6 `/services` buckets are discoverable +only via XML sitemap.** Sitemap-only URLs get crawled late, ranked weakly, and +receive no internal PageRank. This is the single biggest unforced loss in the +audit, and it is cheap to fix. + +**Fix — build the hub-and-spoke that the architecture already implies:** + +```tsx +// 1. app/services/page.tsx — link the 6 bucket hubs from the /services hub +// (currently zero). Add a grid section above