SEO¶
How the PWA talks to search engines. The goal is that on cutover day we flip a single env var and Google starts indexing the new site with clean canonicals, rich results, and a sitemap — no last-minute scramble.
The indexability switch¶
Two env vars control everything:
When SEO_INDEXABLE is off (default, used on dev):
robots.txtreturnsUser-Agent: * · Disallow: /- Every page emits
<meta name="robots" content="noindex, nofollow"> sitemap.xmlrenders empty
When it's on (production):
robots.txtallows crawling, disallows/api/,/cart,/checkout,/account- Pages emit
index, follow sitemap.xmllists static pages, every product, every category, every box-builder templaterobots.txtadvertises the sitemap
SEO_SITE_URL is the canonical host everywhere — every <link rel="canonical">, og:url, JSON-LD url, sitemap entry, and the metadataBase in the root layout all come from it. Even on dev, canonicals point at fruitplug.co.uk so any accidental crawl attributes ranking to the real site.
Structured data (JSON-LD)¶
Rich results eligibility — every schema below renders as <script type="application/ld+json"> server-side.
| Surface | Schemas emitted |
|---|---|
/ (home) |
Organization + WebSite (with SearchAction) |
/p/[slug] (PDP) |
Product (with Offer, AggregateRating if reviews exist) + BreadcrumbList |
/shop/[category] |
BreadcrumbList + CollectionPage |
Helpers:
- Builders:
apps/web/lib/seo/structured-data.ts—organizationSchema(),websiteSchema(),productSchema(product),breadcrumbSchema(crumbs),collectionPageSchema(params). - Renderer:
apps/web/components/seo/JsonLd.tsx—<JsonLd data={...} />. Serializes, escapes<to prevent HTML injection, supports one schema or an array.
When you add a new page template that deserves structured data, build the schema in structured-data.ts, then drop <JsonLd data={...} /> at the top of the component.
Canonicals + OG¶
Every SSR page (home, shop, category, PDP, box-builder) sets:
alternates: { canonical: canonicalUrl("/path") }openGraph: { url, title, description, images, type, ... }
metadataBase is set once in the root layout from SEO_SITE_URL, so relative paths in openGraph.images resolve to absolute URLs automatically.
Sitemap + robots¶
Both are App Router dynamic routes:
apps/web/app/sitemap.ts— reads products + categories from the Woo Store API, adds static pages and box-builder templates. 1h revalidate.apps/web/app/robots.ts— rule-based onSEO_INDEXABLE.
The sitemap intentionally re-fetches from Woo on each revalidate, so newly published products appear within the hour without a deploy.
Cutover checklist¶
When flipping DNS to the new PWA:
- Set
SEO_INDEXABLE=1on the production web.env. - Restart the service; verify
curl /robots.txtreturnsAllow: /. - Verify
curl /sitemap.xmlreturns >100 URLs (all products + categories + statics). - Spot-check
/p/mangosteenfor<link rel="canonical">and the two JSON-LD blocks. - In Google Search Console: resubmit the sitemap and request indexing for home.
- Monitor Coverage report for crawl errors for 7 days.
Yoast passthrough¶
Product pages layer Yoast-authored metadata over the Woo defaults. The flow:
- The fruitplug-api WP plugin exposes
GET /wp-json/fruitplug/v1/seo/product/{slug}(wp-plugin/fruitplug-api/includes/Rest/SeoController.php). It looks the product up viaget_page_by_path, reads_yoast_wpseo_*postmeta directly (noWPSEO_*class dependency), and returns a flat JSON with title/description/canonical/og/twitter/robots/schema fields. Each field isnullwhen Yoast hasn't authored it. The endpoint caches per-slug for 5 minutes via a transient and returns 404 only if the product itself doesn't exist. - The PWA helper
apps/web/lib/seo/yoast.ts(getYoastMeta(slug)) fetches that endpoint withnext: { revalidate: 300 }. It never throws — any failure (no env, network error, non-2xx, malformed JSON) returnsnull. app/p/[slug]/page.tsxcallsgetYoastMetaalongsidegetProductBySluginsidegenerateMetadata. Yoast values win when present; Woo defaults (product name, stripped short_description, first image) fill the gaps. If Yoast setsrobots.noindex === true, the page emitsindex: falseregardless ofSEO_INDEXABLE.
If Yoast is uninstalled the endpoint still returns 200 with all-null fields and the page renders identically to today.
Not shipped yet¶
- hreflang. Single locale (en-GB) for now.
- Image sitemaps. Product images are in the HTML; a dedicated image sitemap is a future optimization.
- Review schema.
AggregateRatingis included when present, but individualReviewobjects aren't. Phase 2 task. - FAQ / HowTo schema on the preparation guide pages — once those land.