Skip to content

feat(seo): A-Z store directory and neighbour links so no store page is orphaned - #238

Open
BSalaeddin wants to merge 1 commit into
seo/index-policyfrom
seo/store-directory
Open

BSalaeddin wants to merge 1 commit into
seo/index-policyfrom
seo/store-directory

Conversation

@BSalaeddin

Copy link
Copy Markdown
Collaborator

Stacked on #236 (seo/index-policy) — merges after it. Base branch of this PR is seo/index-policy; once #236 lands, retarget to dev.

Why

grabcaramel.com has ~4,262 canonical /coupons/<store> pages (after #236) and only 8 of them have any internal inbound link. The only crawlable links into store pages are the 4-item "Popular coupon stores" block (popular-stores.tsx — the same 4 stores on every page) and 8 tiles on /supported-stores (whose grid is otherwise a client-side search). GSC: 4,289 of 4,317 sitemap URLs are unknown to Google, and the sitemap was last read 2025-07-12. Orphan pages do not get crawled or ranked; the sitemap alone is not a discovery mechanism Google trusts here (caramel-artifact/seo-2026-09-11: STATE.md §P0, audit-findings.md §Interlinking, route-inventory.md inbound-link column).

This PR gives every indexable store page crawlable inbound links, without thin pages.

What

  1. A–Z store directory/coupons/stores (letters with real store counts) and /coupons/stores/[letter] (az + 0-9): server-rendered <a href="/coupons/<base>"> for every indexable store, with its live coupon count ("michaels.com · 12 codes"). Both read the SAME data the sitemap emits — listStoreSitemapEntriescollapseStoreRows (fix(seo): canonical store sitemap with lastmod behind one indexability policy #236) — so the directory and the sitemap can never disagree about which stores exist or which are indexable. No second SQL query: a raw site first-character filter would split athleta.gap.com away from gap.com (i.e. re-implement the PSL collapse in SQL). Instead the collapsed list is held in a 5-minute in-process cache (storeDirectoryCache.ts, same shape as fix(ops): stop healthcheck-flap outages — liveness probe, cached supported-stores, edge cache #223's supportedStoresCache.ts: in-flight de-dupe, stale-on-error) because the letter strip now sits on /coupons. A letter with zero stores is notFound(), never an empty page. Metadata: Stores starting with M — coupon codes | Caramel / All stores with coupon codes A–Z | Caramel, self canonical, explicit index, follow. Sitemap lists the index (weekly, 0.8) + only the letters that have stores (weekly, 0.6).
  2. Neighbour links on every store page — a server-rendered "More stores" section with the 5 alphabetically previous and 5 next indexable stores (one repo fn listNeighbourStoreRows(base, n): two indexed range scans site < $base ORDER BY site DESC LIMIT / site > $base ORDER BY site ASC LIMIT over the same visible-coupon aggregate the sitemap reads, over-fetched 3× and passed through collapseStoreRows so subdomain/mixed-case slugs fold and the current base is excluded) plus links to /coupons/stores/<letter> and /coupons/stores. PopularStores stays. Only the JSX body of [store]/page.tsx is touched (not generateMetadata, which fix(copy): truthful catalog figures and privacy/Honey wording (claim integrity) #233/fix(seo): canonical store sitemap with lastmod behind one indexability policy #236 edit).
  3. Hub links — server-rendered "Browse stores A–Z" strip on /coupons and /supported-stores (outside the client search component, links only letters that have stores), a one-line "Store directory" footer link (Footer.tsx — one line, to ease the fix(seo): AI-crawler allow-list, cf-visitor https redirect, HSTS preload, llms-full.txt #234 rebase), and a one-line llms.txt "Key pages" entry.
  4. Coupon cards: coupon-card.tsx does not render the store domain at all (the domain only appears as the store page's own sidebar title), so there was nothing to turn into a link — left untouched, coupon-card.test.tsx still green.

Link graph after this PR

Every indexable store is reachable in ≤3 clicks from home: footer "Store directory" → /coupons/stores/coupons/stores/<letter>/coupons/<store> (also /coupons and /supported-stores → letter strip → letter → store). On top of that, every store page links its 10 nearest neighbours and its letter page, so the store pages form one connected alphabetical crawl chain instead of ~4,262 sitemap-only islands. Per store page: 4 (popular) + up to 10 (neighbours) + 2 (directory) crawlable store/directory links, all in the raw HTML.

Letter distribution (measured)

Prod's /sitemap.xml was returning 404 at measurement time (2026-09-12 ~08:00 EDT — see "Observed" below), so the distribution was measured on dev.grabcaramel.com/sitemap.xml (4,124 store slugs, pre-#236 raw slugs):

0-9:54 a:313 b:425 c:400 d:205 e:187 f:209 g:169 h:143 i:56 j:76 k:74 l:151 m:211
n:85 o:81 p:213 q:18 r:133 s:367 t:248 u:60 v:78 w:115 x:6 y:19 z:28

Every letter has stores (x has 6, q 18); the biggest bucket is b at 425, then c 400, s 367, a 313. No letter paginates today, but b is at 85% of the cap and prod carries ~140 more bases than dev, so ?page=N splitting is wired now (DIRECTORY_PAGE_SIZE = 500): page 1 is the un-paged URL; pages ≥2 carry ?page=N, rel=prev/next, canonical to page 1, noindex, follow, and are absent from the sitemap. Out-of-range or malformed ?page= is a 404.

Largest letter page — HTML size

Measured locally with 501 synthetic b… stores (one over the cap, so page 2 exists) against a production build (next build && next start): /coupons/stores/b (500 stores) = 172 KB raw (26.6 KB gzip over the wire) total HTML — page shell alone is 49 KB (/coupons/stores index), so a store row costs ~0.25 KB; for reference the existing store page /coupons/ebay.com is 102 KB raw / 23 KB gzip on the same build. This is above the ~150 KB soft target; DIRECTORY_PAGE_SIZE = 500 was kept on purpose because dropping to 400 would already split b (425) into a 400-store page plus a 25-store noindex page 2 — one indexable page carrying all 425 links is the better crawl outcome, and 27 KB compressed is what a crawler actually fetches. Easy to lower later (one constant + one test fixture). The list <ul> itself is 37.9 KB; the rest is the page shell + the RSC payload twin of the list. Per-item markup is deliberately bare (<li><a href>base</a> · N codes</li>, styling via parent child-selectors, plain <a> not next/link so 500 dynamic routes are not prefetched per view).

Verification

Gates (worktree C:\wt-caramel-directory, fresh pnpm install --frozen-lockfile + db:generate):

  • pnpm -r run type-check — caramel-ui, caramel-app, caramel-extension: Done (0 errors)
  • pnpm --filter caramel-app testTest Files 86 passed (86), Tests 803 passed (803)
  • pnpm lintTasks: 2 successful, 2 total (only the 3 pre-existing no-console warnings in caramel-extension)
  • pnpm lint:oxlint — exit 0; no warnings in files added here (the two remaining hits under touched paths — sitemapStores.ts:90 no-array-sort, coupon-filters.tsx:20 — are pre-existing)
  • pnpm prettier-checkAll matched files use Prettier code style! (both packages)
  • pnpm --filter caramel-app knip — clean
  • pnpm --filter caramel-app exec prisma validateThe schema at prisma\schema.prisma is valid
  • test:integration tests/integration/coupons-read.itest.ts against the compose Postgres (:58005, migrated + seeded) — 6 passed (6), including the new listNeighbourStoreRows case (ebay.com → before [codecademy.com, amazon.com], after [target.com, walmart.com]; edge amazon.com → empty before window)
  • Local route walk on the built app with the 5-store seed + 501 synthetic stores: /coupons/stores 200 / self canonical / index, follow / 6 letter links; /coupons/stores/b 200 / 500 store links / rel=next / aria-current; /coupons/stores/b?page=2 200 / canonical → page 1 / noindex, follow / 1 store / rel=prev; /coupons/stores/b?page=3, /q, /zz404; /coupons/b0250-dirtest.com → "More stores" with exactly b0245–b0249 + b0251–b0255 and /coupons/stores/b; the subdomain fixture us.b0001-dirtest.com folded into b0001-dirtest.com (no self-link, no raw-slug link); /coupons + /supported-stores carry the strip; footer link present; /sitemap.xml lists /coupons/stores + the 6 populated letters and no ?page= URL; /llms.txt lists the directory.
  • playwright test e2e/seo-a11y.spec.ts against the production build (PLAYWRIGHT_START_SERVER=false, no DATABASE_URL = the deployed-context mode): 18 passed, 1 failed — the failure is the pre-existing /support is listed in sitemap.xml spec, which compares against the local baseURL (:58021) while the sitemap emits .env's BASE_URL (:58000); an artefact of the ad-hoc port, not of this PR (CI runs both on :58000). All four new directory specs and the amended canonical-<loc> spec passed.

Tests added: storeDirectory.test.ts (bucketing, param parsing, page splitting, neighbour collapse), storeDirectoryCache.test.ts (TTL, stampede de-dupe, stale-on-error), couponsRepo.test.ts (neighbour SQL shape + bound params, mocked-prisma style), coupons-store-directory.test.ts (index/letter metadata, notFound() on empty/invalid letter and bad ?page=, page-2 noindex+canonical, strip and neighbours components), sitemap.test.ts (directory entries; store-URL assertions now exclude /coupons/stores), coupons-read.itest.ts (real-pg neighbour windows), e2e/seo-a11y.spec.ts (index 200 + canonical + ≥1 letter link; letter page discovered from the index has store links that are their own canonical base and show counts; store page discovered from a letter page has "More stores" with ≥1 neighbour and links back to the letter; /coupons/stores/zz is 404) — all deployment-safe (URLs discovered from served HTML, no DATABASE_URL gate, no @prisma/client import).

What CI covers

  • checks-app.yml validate: type-check, oxlint, eslint, prettier, knip, unit suite (all of the above).
  • Integration (DB) job runs coupons-read.itest.ts against the CI Postgres — the new neighbour case runs there.
  • E2E & Visual Regression (PR) runs seo-a11y.spec.ts against the hermetic seeded app (5 stores → letters a/c/e/t/w); (push) runs it against the deployed site with the real catalog — the new specs assert only what holds in both.
  • Lighthouse budgets run on the existing pages; the new routes are not in its URL list (owner question below).

Observed while measuring (not caused by this PR)

https://grabcaramel.com/coupons and https://grabcaramel.com/sitemap.xml returned 404 Not Found (text/plain) at ~08:00 EDT 2026-09-12 with a Chrome UA, while /api/health/db was ok (buildRev 4da723b) and /robots.txt was 200. /api/coupons/filters also 404'd (Next's HTML 404). dev.grabcaramel.com served its sitemap normally. Worth a look before/independent of merging.

Owner questions

  1. Should the new directory routes be added to the Lighthouse URL list (mobile budget gate from feat(support): support emails deep-link to the feedback event in PostHog #214-style work)?
  2. /coupons/stores priority 0.8 and letters 0.6 in the sitemap — fine, or should letters sit at 0.5 below store pages (0.7)?
  3. After fix(seo): canonical store sitemap with lastmod behind one indexability policy #236 + this land, re-submit the sitemap in GSC/Bing (owner item from the SEO program) — the directory pages give Google a fresh crawl entry point.

Do not merge — stacked on #236.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant