Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,709 changes: 1,709 additions & 0 deletions SEO-AUDIT.md

Large diffs are not rendered by default.

305 changes: 305 additions & 0 deletions SEO-REVIEW-HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
# SEO work — independent review brief

**Purpose:** hand this file to a fresh Claude (or any second reviewer) and ask it
to check the work. It is written to be **self-contained** — a reviewer with no
access to this repository can still challenge every claim, because the reasoning
and the actual code are reproduced inline.

**Date:** 2026-07-31 · **Repo:** taskly.ca @ `main` · **Companion:** `SEO-AUDIT.md`
(the full audit; this file is the verification brief for what was *done*).

---

## 0. How to use this file

Paste it into a new conversation with this prompt:

> Review the SEO work described in this file. It is a self-assessment written by
> the agent that did the work, so treat it as a claim to be tested, not a
> finding. For each item in §3, decide whether the reasoning is sound, whether
> the code shown actually does what the description says, and whether the fix
> creates a new problem. Pay particular attention to §5 — the things the author
> says are unverified — and tell me which of those would change the conclusions
> if they turned out differently. Be adversarial.

---

## 1. Honest status: is this "end to end"?

**No.** The *audit* is end to end. The *implementation* is partial, by design —
it stopped where a business decision was needed rather than guessing.

| Phase | Status |
|---|---|
| Audit — map every route, rendering mode, metadata, schema, sitemap, robots, hreflang, IA, keywords, performance | **Complete** (`SEO-AUDIT.md`) |
| Wave 1 — correctness & indexing fixes | **~70%** — see §3 for what shipped and §4 for what didn't |
| Wave 2 — internal linking, programmatic pages | **Not started** |
| Wave 3 — content | **Not started** |
| Live verification (crawl / Search Console) | **Not done** — no access; see §5 |

Anyone claiming this is finished is wrong. The largest single item — deciding
how three duplicate domains consolidate — is deliberately still open.

---

## 2. Context a reviewer needs

- One Next.js 16 (App Router) codebase serves **three live ccTLDs**:
`tasklyanything.ca` (Canada, the revenue market), `tasklyanything.in` (India,
not launched), `tasklyanything.ai` (global gateway).
- The market is resolved from the **Host header**, not a path prefix or i18n
router.
- Two flags govern behaviour. Both were measured, not assumed:
- `NEXT_PUBLIC_REGION_ROUTING_LIVE` — **false**. So `resolveRegion()` pins
*every* host to `'ca'`, and all three domains serve identical Canadian
content.
- `NEXT_PUBLIC_MULTI_REGION_LIVE` — **false**. So **no `hreflang` is emitted
at all**.
- Critically, SEO tags do **not** use the gated resolver. They use a pure
`regionFromHost()` mapping, so each domain self-describes correctly even while
routing is dark. That distinction is load-bearing and easy to get wrong.

**How the live state was measured** (a reviewer should sanity-check this
inference):
`tasklyanything.in/pricing` was fetched and returned the *full product* with `$`
CAD pricing, the footer "Built with ❤️ in Toronto", and example cities Toronto /
Mississauga / Markham. If the region had resolved to `'in'`, those would read
"Built with ❤️ in India" and Mumbai / Delhi / Bengaluru / Pune. Therefore
routing is off. `tasklyanything.in/sitemaps/static.xml` returned **HTTP 200**
listing `/seasonal`, `/cost`, `/compare/taskrabbit` and `/about` under the `.in`
host.

---

## 3. What was changed — check each of these

### 3.1 Canada-only pages were being advertised on the India domain

**Claim:** `staticSitemap()` took only an origin, so every host received the same
path list. Measured live: `.in` was publishing `/seasonal` (snow removal),
`/cost` (CAD price guides), `/compare/taskrabbit` and `/about`.

`/about` is the sharper half: it is gated to Canada at the *page* level, so the
sitemap was submitting a URL the page itself refuses to serve outside Canada.

**Fix:**

```ts
// lib/seo/sitemaps.ts
const CA_ONLY_EXACT = new Set<string>(['/seasonal', '/seasonal/taskers', '/cost', '/price-guide']);
const CA_ONLY_PREFIXES = ['/cost/', '/compare/', '/book/', '/hire/'];

function pathInMarket(path: string, region: Region, visibility?: PageVisibility): boolean {
// Rule 1 — never contradict the page's own visibility gate.
if (!pathAllowedInRegion(path, region, visibility)) return false;
// Rule 2 — Canada-only families stay off the other markets' sitemaps.
if (region === 'ca') return true;
if (CA_ONLY_EXACT.has(path)) return false;
return !CA_ONLY_PREFIXES.some((prefix) => path.startsWith(prefix));
}

export function staticSitemap(origin: string, region: Region, visibility?: PageVisibility): SitemapUrl[] {
const paths = [ /* …unchanged list… */ ].filter((p) => pathInMarket(p, region, visibility));
return paths.map((p) => ({ loc: `${origin}${p}`, lastmod: PAGE_LASTMOD[p] ?? SITE_LASTMOD }));
}
```

`hireSitemap` and `hubsSitemap` additionally return `[]` when `region !== 'ca'`.

**Worth challenging:**
- Is `pathAllowedInRegion` the same predicate the page guard uses? (Intent: yes,
including admin overrides, which is why the resolved visibility map is passed
in from the route handler rather than defaulted.)
- `CA_ONLY_PREFIXES` is a hardcoded list. Is a deny-list the right shape, or
should market ownership be a property of each page family?
- `/hire` and `/on` are gated with `region !== 'ca'`. That is correct today but
hardcodes "Ontario == the only market with city pages". Flagged in-code.

### 3.2 India was an unconsolidated duplicate

**Claim — this is the subtlest item, and the one most worth attacking.**
`hreflangLanguages()` already excluded `.in` from the cluster while India is
unlaunched. But `.in` was still *indexable* and still *sitemapped*. A domain
outside the hreflang cluster, serving identical content, self-canonicalising, is
a duplicate with no consolidation signal — which is precisely what hreflang is
meant to prevent.

**Fix — one predicate, three consumers:**

```ts
// lib/region/seo.ts
export function marketIndexable(region: Region): boolean {
return region !== 'in' || indiaMarketplaceLive();
}
```

| Consumer | Effect when `marketIndexable('in') === false` |
|---|---|
| `hreflangLanguages()` | `.in` excluded from the cluster (pre-existing) |
| `app/layout.tsx` | `.in` pages ship `robots: { index: false, follow: true }` (**new**) |
| `app/sitemaps/[file]/route.ts` | `.in` child sitemaps return an empty `<urlset>` (**new**) |

Pinned by a test asserting, for **both** values of the India flag, that a market
is in the hreflang cluster **if and only if** it is indexable:

```ts
for (const r of ALL_REGIONS) {
const url = `${originFor(r)}/pricing`;
expect(inCluster.has(url)).toBe(marketIndexable(r));
}
```

**Worth challenging:**
- Is `noindex` the right call versus cross-canonicalling `.in` → `.ca`? The
argument for `noindex`: India is not a market yet, so there is no equity to
consolidate and `follow: true` still passes link value through. The argument
against: `noindex` discards any accumulated signal on `.in`.
- Does the empty-sitemap-not-404 choice hold up? (Intent: the index stays valid
and self-fills at launch.)
- **Does this actually fix anything given `MULTI_REGION_LIVE` is off?** It
removes `.in` from the duplicate set regardless — but a reviewer should verify
that claim rather than accept it.

### 3.3 Two never-redirect lists had drifted

**Claim:** `geo-route.ts` exempted `/robots.txt`, `/sitemap.xml` **and**
`/sitemaps/`; `waitlist.ts` exempted only the first two. Dormant today (the
waitlist gate is dark), but at India launch `.in/robots.txt` would advertise a
sitemap index whose six children all 307 to `/waitlist`.

**Fix:** a shared `isCrawlMetadataPath()` in `lib/region/crawl-paths.ts`, adding
the missing `/sitemaps/` **and** `/llms.txt`, now imported by both.

**Worth challenging:** is a shared predicate the right abstraction, or does it
couple two gates that should stay independent?

### 3.4 Brand name printed twice in Google

**Claim:** the root layout templates titles as `'%s | Taskly'`. Three pages
shipped a plain title string already ending in `| Taskly`, rendering
`… | Taskly | Taskly`; two exceeded the display limit and truncated mid-brand.

| Page | Rendered before | Chars | After | Chars |
|---|---|---|---|---|
| `/cost` | `Price estimator — what will your task cost? \| Taskly \| Taskly` | 61 | unchanged text, `absolute` | 52 |
| `/price-guide` | `2026 GTA price ranges: … \| Taskly \| Taskly` | 74 | `2026 GTA home-services price ranges \| Taskly` | 44 |
| `/tasker-guide` | `How to earn on Taskly in Toronto — … \| Taskly \| Taskly` | 78 | `How to earn on Taskly in Toronto \| Taskly` | 41 |
| `/blog` | `The Taskly Journal — stories, guides & costs for the GTA & Canada` | 74 rendered | `The Taskly Journal — guides & real costs` | 49 |

Fixed with `title: { absolute: '…' }`, matching the convention `/services` and
`/the-taskly-advantages` already used.

**Worth challenging:** the shortened `/price-guide` and `/tasker-guide` titles
dropped keywords ("what home services typically cost", "the founding Tasker
guide"). Is that trade correct, or should the length have been solved
differently?

### 3.5 Organization schema claimed Toronto on every domain

**Claim:** the JSON-LD `Organization` node hardcoded `addressLocality: 'Toronto'`,
`addressCountry: 'CA'` and 15 GTA `areaServed` cities on all three hosts — while
the function directly above it carefully region-scoped the *description* for the
same reason.

**Fix:** only `region === 'ca'` emits the locality and city list; other markets
keep the country of incorporation (a fact) and drop the service-area claim.
Separately, schema `email` was `hello@taskly.ca` while every human-facing surface
said `care@taskly.ca` — unified to the latter as `BRAND.email`.

**Worth challenging:** is keeping `addressCountry: 'CA'` on `.in` right, or
should the India entity assert `IN`? (Reasoning used: country of incorporation is
a verifiable fact; service area is a claim.)

### 3.6 Minor

`/snowsquad` used `redirect()` (307) for a permanent alias → `permanentRedirect()`
(308).

### 3.7 Verification actually run

```
tsc --noEmit exit 0
vitest run 1459 passed · 97 skipped · 0 failed
eslint . exit 0 — 0 errors (211 pre-existing warnings)
knip exit 0
check:migrations exit 0 — 195 migrations, numbering OK
```

Three new sitemap tests and four new hreflang/indexability tests were added.

---

## 4. What was deliberately NOT done

1. **The duplicate-domain decision (the biggest item).** `.ca` and `.ai` serve
identical content with no hreflang and no cross-canonical. Two mutually
exclusive fixes exist — publish the hreflang cluster, or temporarily
cross-canonical onto one primary. Choosing *which domain wins* is a business
call. **Left open, deliberately.** Note it is an env change plus a full
rebuild, not a code edit: `NEXT_PUBLIC_*` values are inlined at build time.
2. **`NEXT_PUBLIC_SITE_URL` points at `https://taskly.ca`** — the abandoned
WordPress domain, not any of the three live hosts. It is the fallback origin
for transactional email links and SSR share links. Not fixed because
`lib/email/*` had uncommitted concurrent edits from another session; editing
would have collided.
3. **14 pages hardcode "GTA"/"Toronto" in metadata** served on all three
domains. Needs copywriting, not engineering.
4. **Internal linking.** ~68 of 72 `/hire/{service}/{city}` pages and all 6
`/services/{bucket}` hubs have no inbound internal link — discoverable only
via sitemap. Highest-ROI remaining engineering work; no new content required.
5. **India copy.** Infrastructure is right (en-IN, ₹, Asia/Kolkata, Mumbai/Delhi/
Bengaluru/Pune); page copy is entirely Canadian. Also: India's market phrase
is `"your city"`, which renders the waitlist headline as *"Taskly is launching
in your city"*.

---

## 5. What could not be verified — attack these first

A reviewer should treat everything here as capable of overturning §3.

1. **No live rendered-HTML verification.** Titles, descriptions and hreflang were
read from the code that generates them, not scraped. The fetch tool available
strips the exact tags under audit, and `curl` was sandbox-blocked. The code is
authoritative for *what will render*, but nothing confirms the deployed build
matches this source tree.
2. **No Search Console / analytics access.** No rankings, impressions, CTR or
indexation data. Every priority is reasoned from code, not measured from
performance.
3. **Production env values were inferred, not read.** The flag states in §2 were
deduced from observed page content. Reading the actual Railway variables
could contradict them — and if `REGION_ROUTING_LIVE` is actually *true*, parts
of §3 change meaning.
4. **Migration 155 (`region` columns) applied-state unknown.** The `/hire`
region-filter fix described in `SEO-AUDIT.md` §3.2 C-3 was *not* applied for
this reason — if the column is missing, the query throws, the `catch` returns
`[]`, and every `/hire` page silently goes noindex.
5. **Cloudflare cache keying unverified.** The root layout reads `headers()`, so
every route is dynamic and per-host caching is delegated to Cloudflare. If
that cache is not keyed on Host, one market's HTML can be served on another's
domain — which would outrank every finding in the audit.
6. **`taskly.ca` — unknown what it currently serves.** If it hosts a competing
copy of the site rather than redirecting, item §4.2 is Critical, not High.
7. **Heading hierarchy was spot-checked, not exhaustively crawled.** One earlier
finding ("double H1 on `/services`") was **wrong** and withdrawn — it was a
component-name collision between two different `Pricing` components. That
error is disclosed because it suggests other spot-checks may be similarly
fragile.
8. **Keyword work contains zero search volumes.** No keyword tool was available;
nothing was estimated. Clusters are built from strings already in the codebase.

---

## 6. Specific questions for the reviewer

1. In §3.2, is `noindex` on `.in` better or worse than cross-canonical to `.ca`?
2. In §3.1, does the deny-list of Canada-only paths risk silently dropping a page
from a market it *should* appear in as the site grows?
3. Is the `marketIndexable` invariant (cluster membership ⟺ indexability)
actually always true, or are there legitimate cases where a page should be
indexed but excluded from the cluster?
4. Given `REGION_ROUTING_LIVE` is off and all three domains serve identical
content, is fixing the *sitemaps* per market meaningful — or cosmetic until
the body copy is also per market?
5. In §3.4, were the shortened titles the right trade against lost keywords?
6. What in §5 would you verify first?
5 changes: 4 additions & 1 deletion app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import { marketingAlternates } from '@/lib/region/seo';
import { cn } from '@/lib/utils';

export const metadata: Metadata = {
title: 'The Taskly Journal — stories, guides & costs for the GTA & Canada',
// Shortened to fit the ~60-char SERP budget: the old string was 65 chars and
// the root '%s | Taskly' template pushed the rendered title to 74, truncating
// the brand off the end.
title: 'The Taskly Journal — guides & real costs',
description:
'Why Taskly exists, the people behind the tasks, and honest cost guides for getting things done across Canada — cleaning, personal chefs, handymen, moving, pet care, tutoring and more. Compare fixed-price offers; pay only when it’s done.',
alternates: marketingAlternates('/blog'),
Expand Down
5 changes: 4 additions & 1 deletion app/cost/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ const SITE = process.env.NEXT_PUBLIC_SITE_URL || 'https://tasklyanything.ca';
// guide targets "typical 2026 price ranges by service". They cross-link rather
// than canonicalise: the content is genuinely different.
export const metadata: Metadata = {
title: 'Price estimator — what will your task cost? | Taskly',
// `absolute` — the root layout templates titles as '%s | Taskly'
// (app/layout.tsx), so a plain string ending in '| Taskly' renders
// "… | Taskly | Taskly" in the SERP. Matches /services + /the-taskly-advantages.
title: { absolute: 'Price estimator — what will your task cost? | Taskly' },
description:
'Describe your task and get an instant GTA price estimate. Type it, snap a photo, or just say it — see a realistic Budget / Standard / Premium range in seconds, then post free and compare real offers.',
alternates: marketingAlternates('/cost'),
Expand Down
Loading
Loading