Skip to content

feat(seo): storefront metadata, structured data, sitemap and robots - #26

Merged
kris1027 merged 12 commits into
mainfrom
feat/storefront-seo
Oct 24, 2026
Merged

kris1027 merged 12 commits into
mainfrom
feat/storefront-seo

Conversation

@kris1027

Copy link
Copy Markdown
Owner

What

Every public storefront page now has full search and share metadata: a unique title and description, an absolute canonical link, and an Open Graph share card. Product pages also carry Product structured data (JSON-LD) with one Offer per variant, the "Was" price and the delivery fee. The store gains a robots.txt that blocks crawling everywhere except production, a sitemap of every public page, and a generated brand share card and logo. Nothing in the database changes.

Why

Implements spec 0018 (scope feature 17, Launch readiness). Shoppers should see real price, availability and sale in search results and a proper image when a link is shared; the store owner wants every public product and category found and nothing hidden indexed; and preview deploys must never compete with the real store in search. Options and reasoning are in rationale.md.

Changes

Shared SEO helpers (src/lib/seo/)

  • pageMetadata builds title, description, absolute canonical, Open Graph and Twitter card for every indexable page. It replaces legalMetadata and the category page's hand written metadata. og:locale comes from STORE_LOCALE (pl_PL style, omitted for a bare language).
  • The root layout sets metadataBase from NEXT_PUBLIC_SITE_URL, so every URL is absolute on the site's own origin.
  • plainExcerpt turns Markdown into plain text and cuts it at a word boundary at 160 characters with …. It uses mdast-util-from-markdown and mdast-util-to-string (new direct dependencies, already inside the react-markdown tree).
  • JsonLdScript is the one place JSON-LD is written, and it escapes every <, so admin text holding </script> cannot break out of the tag. JSON-LD is typed with schema-dts (dev dependency, types only).
  • robotsRules(vercelEnv, siteUrl): under VERCEL_ENV=production, allow /, disallow /admin, /api, /auth, and name the sitemap. Anything else (preview, local, CI, unset) is Disallow: /.

Product pages

  • Title and description from meta_title / meta_description, falling back to the name and the description's plain text. og:image is the first gallery photo, or the brand card when there is none.
  • Product JSON-LD with every gallery image, the brand, and one Offer per live variant: SKU, price as a decimal string, currency, InStock / OutOfStock (low stock counts as in stock, and no stock count is ever written), NewCondition, a StrikethroughPrice when the compare at price is above the price, and shippingDetails from the same shippingCents rule the cart uses, read from the cached settings so an admin save shows on the next request.
  • getProductBySlug now also returns metaTitle, metaDescription and each variant's sku.
  • The script mounts after the not found check, so a hidden product gets none.

Listings (home and category)

  • A pure listingCanonical rule on the parsed listing state: any filter or non default sort points at the plain path; an unfiltered paged view points at ?page=N, where N is the page actually shown. shownPage is now exported from listing.ts and used by both buildListing and the canonical rule, so they cannot drift. Unknown params like ?utm_source=x are ignored.
  • Paged titles gain , page N. No search or filter text ever reaches metadata.
  • Category descriptions fall back through plainExcerpt. Category pages render a BreadcrumbList; the home page renders Organization and WebSite JSON-LD in its static shell.
  • /search stays noindex, follow, with no canonical, and now uses the brand card.

Brand images

  • /brand-card.png (1200x630) and /logo.png (512x512) are fixed path next/og route handlers, prerendered at build. app/icon.tsx reuses the logo as the tab icon; favicon.ico stays.
  • New brand.colors in src/lib/brand/brand.ts: sRGB hex copies of the background and foreground tokens, since next/og cannot read oklch.

Sitemap

  • /sitemap.xml lists home, the five legal pages (dated by their updated front matter), every visible category, and every active product with a live variant plus its image URLs. Nothing else.
  • Reads go through two 'use cache' functions tagged catalog and categories, so an existing product or category write shows up on the next request with no deploy.

Docs: spec 0018 with rationale and verify steps, scope feature 17 marked done (plus a new parked item for return window and delivery time as data), and src/features/legal/AGENTS.md updated for legalSeo.

How to test / verify

  • pnpm install (two new dependencies, one new dev dependency). No migration.
  • pnpm test for the metadata helper, excerpt, JSON-LD escaping, structured data builders, robots rule (both branches), canonical rule, product SEO and the sitemap composition.
  • pnpm test:db -- tests/db/sitemap.db.test.ts for drafts, archived and variant less products and hidden categories staying out of the sitemap.
  • pnpm test:e2e tests/e2e/seo for product, category, home, search and legal heads, JSON-LD, paged canonicals, the sitemap, robots, and hidden and private pages staying noindex.
  • By hand: curl -s localhost:3000/robots.txt shows Disallow: / locally. Open /brand-card.png and /logo.png. When reading a page's head, send a Googlebot user agent, since Next only blocks on metadata for known bots.

The full checklist with results is in verify.md.

Risk & rollout

  • No migration, no flags. Rollback is reverting the branch.
  • Home and category metadata now render at request time, because they read searchParams for the canonical page. Normal browsers get the head streamed after the shell; crawlers still get it up front. The results were already request time, so the shells stay as they were.
  • Robots defaults to blocking. Only a build with VERCEL_ENV=production allows crawling, and robots.ts prerenders at build, which matches how Vercel builds production deployments.
  • Two verify steps are still open: running one product page through Google's Rich Results Test on a preview build, and the by hand check that publishing or hiding a product changes the sitemap with no deploy, including on pnpm build && pnpm start (the e2e test "the sitemap follows a publish and a hide on the next request" already covers it).
  • Google's merchant report will warn about the missing return policy and delivery time until those exist as real data (parked in the scope).
  • Before launch, the "Was" price marked as StrikethroughPrice needs to be the lowest price of the previous 30 days (EU Omnibus rule); see rationale.md.

Notes for reviewers

  • Worth extra eyes: the canonical rule in src/features/catalog/listing/canonical.ts and its use of shownPage, and the Offer building in src/features/catalog/seo.ts.
  • The brand card and logo are fixed path route handlers rather than the opengraph-image file convention on purpose: the convention gives a hashed URL the Organization logo cannot name, and Next merges openGraph shallowly, so a child page could drop the inherited image.
  • This narrows spec 0013 AC-24: unfiltered paged listings now canonicalize to themselves, not the plain path.
  • No /check review record exists yet for this branch in docs/reviews/.

🤖 Generated with Claude Code

kris1027 and others added 5 commits October 23, 2026 17:04
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ories

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kris1027 kris1027 added the feature New user-facing functionality label Oct 23, 2026
@kris1027 kris1027 self-assigned this Oct 23, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in BeStore Oct 23, 2026
@kris1027 kris1027 moved this from Backlog to In review in BeStore Oct 23, 2026
kris1027 and others added 7 commits October 23, 2026 17:38
The category header shows its description as plain text, so parsing it as
Markdown dropped characters the page keeps (*, #, [x](y)) and the meta
description no longer matched the page. textExcerpt applies the AC-6 cut
to plain text; plainExcerpt now builds on it for product Markdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…own module

- brand-assets.ts holds the brand card and logo paths and sizes, so
  metadata.ts is only the page metadata builder
- brandCardResponse and logoResponse share one renderer
- pageMetadata builds the description field once
- drop twitter title and description: spec 0018 AC-2 asks only for the
  card and images, and Next fills the text from openGraph (e2e checks it)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the site facts

Every storefront route now calls storePageMetadata(page) from site.ts
instead of importing both pageMetadata and site; metadata.ts stays pure for
its tests. The product page takes the store facts from site.ts instead of
building them from env inline, and StoreFacts types the country as
StoreCountry. Also fixes the import order in legal-page.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildListing branched on scope.kind again after inScopeProducts had, and
split the search words twice. scopeOf now returns the in scope products
with their featured and default orders; inScopeProducts reads from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge's noindex

- spec 0018 build plan 5: a category shown and hidden in the admin appears
  in and leaves the sitemap on the next request (AC-19)
- AC-22: checkout complete, the account and admin auth pages, every signed
  in account page and every admin panel page keep noindex with no
  canonical or JSON-LD; /checkout waits for its redirect to the cart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… AGENTS.md

- AC-6 says the 160 characters include the ellipsis; AC-5 and AC-14 say a
  blank meta field counts as unset; AC-3 names the brand colour test
- verify: AC-19 ticked (dev locally, the build through CI e2e), the AC-22
  step widened; the Rich Results Test stays open until a preview deploy,
  now a spec follow-up and noted on the scope's Verify step
- catalog, categories and legal AGENTS.md: the canonical and SEO files,
  the sitemap reads, storePageMetadata, the plain text category rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kris1027
kris1027 merged commit 7e00d5b into main Oct 24, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in BeStore Oct 24, 2026
@kris1027
kris1027 deleted the feat/storefront-seo branch October 24, 2026 09:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New user-facing functionality

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant