PPM

@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

VersionPublishedIntegrity
1.5.2 latest2026-10-01sha512-Tgh/px9aNHHo8vm…
1.5.1 2026-10-01sha512-g/rysAp5mGl5GPa…
1.5.0 2026-10-01sha512-rEsFXWyj2sOxJ0Y…
1.4.0 2026-09-30sha512-n9CIWN8V5+dx15y…
1.3.0 2026-09-15sha512-03UyE16QtgVu5ta…
1.3.1 2026-09-15sha512-lOckRY5bnv1doRD…
1.2.3 2026-09-09sha512-OSxwjuZty1HQPiO…
1.2.2 2026-09-03sha512-eeYkVujrvQstEDf…
1.2.1 2026-09-03sha512-/6O4mFUZ9l9EaSf…
1.2.0 2026-09-03sha512-JmuFvPXuTeHhijU…
1.1.0 2026-09-03sha512-PSpJkYiG5vUkmil…
1.0.0 2026-08-08sha512-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:

  1. 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 real dist/<route>.html.
  2. 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).
  3. Fragmented identity — every site emits the same Organization + sameAs graph 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)

  1. Install:

    npm i @pixygon/seo
    npm i -D playwright@1.60.0
    
  2. 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/html
    

    Keep the image tag in lock-step with the playwright version.

  3. nginx.conf — serve the prerendered files before the SPA fallback (without $uri.html the prerender does nothing):

    try_files $uri $uri.html $uri/ /index.html;
    

    Strongly recommended — split the homepage from the fallback shell. index.html is 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;
    }
    
  4. Routes — add pixygon-seo.config.json at the repo root (it also auto-discovers from dist/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,/b bakes only those routes.

  5. Head tags — use <PixygonSEO> (or the helpers). Canonicals come from the production domain you pass — never window.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 + sameAs identity graph is emitted automatically. Pass siteSchema={false} on inner routes if you already emit it once per page.

  6. 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

ExportFromUse
prerender(opts?)@pixygon/seo/prerender.mjsthe build-time renderer (also a pixygon-prerender bin)
PIXYGON_SAME_AS@pixygon/seothe canonical 6-social identity array
pixygonOrganization()@pixygon/seothe Organization JSON-LD node
pixygonSiteSchema(opts)@pixygon/seofull @graph: Organization + App + WebSite
dedupeSeoHead(html)@pixygon/seostring-level head dedupe (for custom prerenders)
<PixygonSEO>@pixygon/seo/reactthe 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

All packages · packument JSON