@pixygon/seo
Estate-standard SEO + AIEO for Pixygon web apps: full-page prerender (real content for crawlers & AI answer engines), the canonical Organization/sameAs identity graph, and a production-safe <PixygonSEO> head.
Install
# .npmrc @pixygon:registry=https://ppm.pixygon.io/ npm install @pixygon/seo@1.5.2
Versions
| Version | Published | Integrity |
|---|---|---|
| 1.5.2 latest | 2026-10-01 | sha512-Tgh/px9aNHHo8vm… |
| 1.5.1 | 2026-10-01 | sha512-g/rysAp5mGl5GPa… |
| 1.5.0 | 2026-10-01 | sha512-rEsFXWyj2sOxJ0Y… |
| 1.4.0 | 2026-09-30 | sha512-n9CIWN8V5+dx15y… |
| 1.3.0 | 2026-09-15 | sha512-03UyE16QtgVu5ta… |
| 1.3.1 | 2026-09-15 | sha512-lOckRY5bnv1doRD… |
| 1.2.3 | 2026-09-09 | sha512-OSxwjuZty1HQPiO… |
| 1.2.2 | 2026-09-03 | sha512-eeYkVujrvQstEDf… |
| 1.2.1 | 2026-09-03 | sha512-/6O4mFUZ9l9EaSf… |
| 1.2.0 | 2026-09-03 | sha512-JmuFvPXuTeHhijU… |
| 1.1.0 | 2026-09-03 | sha512-PSpJkYiG5vUkmil… |
| 1.0.0 | 2026-08-08 | sha512-Ta6u+zmGNrnR3Xh… |
@pixygon/seo
The estate-standard SEO + AIEO toolkit for Pixygon web apps. One source of truth so this is never reinvented — or re-bugged — per project.
It solves the three things that were independently broken across six Pixygon SPAs:
- Empty-shell problem — a client-rendered SPA serves
<div id="root">with no content to crawlers and AI answer engines (GPTBot, ClaudeBot, PerplexityBot, CCBot, Googlebot's no-JS pass). The full-page prerender renders each route in headless Chromium at build time and writes realdist/<route>.html. - Duplicate meta — react-helmet(-async) appends its per-route tags on top
of the ones hard-coded in
index.html, so every prerendered route shipped 2× og:image/canonical/title. The prerender dedupes the head (keep-last). - Fragmented identity — every site emits the same
Organization+sameAsgraph so Google reads the estate as one entity.
New projects
Already baked into the website template (Dyson/templates/website) — a
scaffolded project gets all of this for free. Nothing to do.
Adding it to an existing Vite SPA (≈10 minutes)
-
Install:
npm i @pixygon/seo npm i -D playwright@1.60.0 -
Dockerfile — add a prerender stage between build and nginx. ⚠ It must be the Debian jammy Playwright image; Alpine silently degrades the prerender to head-only (empty body):
FROM mcr.microsoft.com/playwright:v1.60.0-jammy AS prerender WORKDIR /app COPY --from=build /app ./ RUN node node_modules/@pixygon/seo/prerender.mjs || true FROM nginx:alpine COPY --from=prerender /app/dist /usr/share/nginx/htmlKeep the image tag in lock-step with the
playwrightversion. -
nginx.conf — serve the prerendered files before the SPA fallback (without
$uri.htmlthe prerender does nothing):try_files $uri $uri.html $uri/ /index.html;Strongly recommended — split the homepage from the fallback shell.
index.htmlis also the SPA fallback for every route you do NOT prerender (/m/:id,/u/:name, …); if the rendered homepage overwrites it, all of those URLs serve the homepage's canonical to crawlers and get deduped out of the index (this cost tastebud ~720 sitemap URLs, found 2026-08-08). Set"homepageFile": "_home"in the config and add:location = / { try_files /_home.html /index.html; } -
Routes — add
pixygon-seo.config.jsonat the repo root (it also auto-discovers fromdist/sitemap.xml):{ "routes": ["/", "/pricing", "/about", "/faq"] }List every PUBLIC content route; leave out auth/app/checkout pages.
Full config options (all optional):
{ "routes": ["/", "/pricing"], // merged with dist/sitemap.xml <loc>s "settleMs": 500, // extra wait after networkidle "homepageFile": "_home", // write "/" to _home.html; keeps the // fallback shell clean (see nginx above) "minBodyTextChars": 200, // refuse to write a render whose <body> // has less visible text than this // (0 disables) — a blank render must // not ship looking "prerendered" "launchArgs": ["--disable-web-security"] // needed when your API sends no CORS // headers for localhost origins: // without it every data fetch fails // during the render and data-driven // pages bake their error/empty states }For a catalogue of hundreds or thousands of pages (all off by default; a site that sets none of them bakes byte-for-byte what it did before):
{ "routeSource": "scripts/prerender-routes.mjs", // default export: async () => string[] "concurrency": 6, // pages rendered at once "recycleEvery": 40, // fresh page per worker every N renders (memory) "blockResources": ["image", "media", "font"], // never fetched while baking "requireLinks": [{ "route": "^/directory/", "href": "/m/" }], // refuse (and retry) a list page that // rendered without the links it exists for "minBodyTextFor": { "/404": 20 }, // a per-route text floor "stripNoscript": true, // drop index.html's crawler fallback "shareCards": { "module": "scripts/share-card.mjs", "origin": "https://example.com" }, // a share card per page: the module exports // cardFor(route) and cardHtml(card, {title, description}) "disableDevShm": true, // Chromium off Docker's 64 MB /dev/shm "launchAttempts": 3 // retry a failed browser launch, 3 s apart }PIXYGON_PRERENDER_ROUTES=/a,/bbakes only those routes. -
Head tags — use
<PixygonSEO>(or the helpers). Canonicals come from the productiondomainyou pass — neverwindow.location.origin:import { PixygonSEO } from '@pixygon/seo/react'; <PixygonSEO domain="https://yoursite.pixygon.io" siteName="YourSite" title="Pricing" description="…" image="https://yoursite.pixygon.io/og-image.png" path="/pricing" />The Organization +
sameAsidentity graph is emitted automatically. PasssiteSchema={false}on inner routes if you already emit it once per page. -
Verify the deploy (do not skip — the Alpine trap is silent):
curl -s -A "Googlebot/2.1" https://yoursite.pixygon.io/pricing | grep -c 'id="root"></div>' # 0 = real content served ✓ 1 = still an empty shell ✗
API
| Export | From | Use |
|---|---|---|
prerender(opts?) | @pixygon/seo/prerender.mjs | the build-time renderer (also a pixygon-prerender bin) |
PIXYGON_SAME_AS | @pixygon/seo | the canonical 6-social identity array |
pixygonOrganization() | @pixygon/seo | the Organization JSON-LD node |
pixygonSiteSchema(opts) | @pixygon/seo | full @graph: Organization + App + WebSite |
dedupeSeoHead(html) | @pixygon/seo | string-level head dedupe (for custom prerenders) |
<PixygonSEO> | @pixygon/seo/react | the per-route head (needs react-helmet-async) |
The node-safe core (.) has no React dependency, so build scripts and the
prerender import it freely; the React component lives at @pixygon/seo/react.
Publishing
npm run build && npm publish --access public