@pixygon/seo
v1.0.0
Published
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.
Maintainers
Readme
@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 [email protected]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;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.
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