Files
convert/docs/superpowers/specs/2026-08-02-seo-fixes-design.md
anthony 9d30ff4c74 docs: add design spec for SEO fixes and prerendering migration
Covers server-side redirects/404, compression/cache headers, dynamic
sitemap, vite-react-ssg prerendering with react-helmet-async, and 30
curated conversion-pair landing pages.
2026-08-02 09:51:40 +02:00

10 KiB
Raw Permalink Blame History

SEO fixes and prerendering — design

Context

A code-based SEO review of the Convert app (frontend/ React+Vite SPA served by src/app.js Express, catch-all to a single index.html) found:

  • Pure client-side rendering: no SSR/prerendering plugin in vite.config.js.
  • Only two indexable URLs exist (/fr/, /en/); all SEO tags (title, meta description, OG, Twitter, hreflang, JSON-LD) are set client-side in a useEffect inside frontend/src/components/SeoHead.jsx, so a crawler that doesn't execute JS sees only the static, generic tags baked into frontend/index.html.
  • / redirects to /fr/ or /en/ via client-side <Navigate> (navigator.language), not a server redirect.
  • The Express catch-all (src/app.js:251-255) returns index.html with an implicit 200 for every unmatched route, including typos — a soft-404.
  • No canonical tag, incomplete OG/Twitter tags, no compression middleware, no explicit Cache-Control on static assets.
  • frontend/public/sitemap.xml is static, listing only the two shell URLs.
  • The app supports 150+ registered conversion pairs (src/converters/*.jsregistry.js) but has zero landing pages targeting specific "convert X to Y" search intent.

This spec covers the full scope the user approved: targeted technical fixes, a build-time prerendering migration, and a curated set of programmatic conversion-pair landing pages.

Goals

  • Every route the app exposes returns fully-formed SEO tags (title, description, canonical, OG, Twitter, hreflang, JSON-LD) in the raw HTML response, with no dependency on JS execution.
  • / redirects server-side (301) by language.
  • Unknown routes return a real HTTP 404.
  • Static assets are compressed and cached; HTML is revalidated on each request.
  • sitemap.xml reflects the real, current set of prerendered URLs.
  • ~30 curated conversion-pair landing pages (×2 languages = 60 URLs) exist, each with genuinely differentiated content, driving long-tail organic traffic to the existing conversion tool.

Non-goals

  • Full request-time SSR (rejected in favor of build-time prerendering — no per-request render cost, no framework rewrite, works with the existing static-file hosting model on o2switch).
  • Generating landing pages for all 150+ registered pairs (would cross the quality-gate hard-stop for thin content; only pairs with real content get a page).
  • Any change to conversion logic itself (src/converters/*, src/worker.js).
  • Adding new conversion pairs — this spec only targets already-registered ones.

Architecture: build-time prerendering with vite-react-ssg

vite-react-ssg renders each configured route once at build time (ReactDOMServer) to a static frontend/dist/<route>/index.html, then the same bundle hydrates normally in the browser. Runtime behavior for users is unchanged; what changes is what a non-JS client (crawler, social-media unfurler, curl) receives.

Why not useEffect for SEO tags with this approach: useEffect never runs during renderToString/renderToStaticMarkup — only after hydration in a real browser. If SeoHead.jsx stays as-is, prerendering would still ship HTML with no SEO tags, defeating the migration. SeoHead.jsx must be rewritten to use react-helmet-async (new dependency, pure JS, no native bindings — safe for o2switch), which collects head tags synchronously during render via HelmetProvider context, on both the server pass and the client.

Changes

  • frontend/package.json / root package.json: add react-helmet-async and vite-react-ssg (frontend needs both for local dev; root needs vite-react-ssg too, per the existing o2switch single-node_modules constraint documented in CLAUDE.md).
  • frontend/src/main.jsx: replaced with a ViteReactSSG entry (exports an app-creation function instead of calling createRoot().render() directly). Routes declared as a single config array consumed by both the prerender pass and client hydration.
  • frontend/vite.config.js: add vite-react-ssg build config, listing every route to prerender (see route list below).
  • frontend/src/components/SeoHead.jsx: rewritten on top of react-helmet-async's <Helmet>; adds canonical (currently missing) and makes hreflang alternates and JSON-LD render synchronously instead of via useEffect. Also rendered from NotFound.jsx (currently not called there), with a noindex meta tag for the 404 page specifically.
  • frontend/src/pages/HomePage.jsx and the new conversion-pair pages (below) consume the rewritten SeoHead.

Express changes (src/app.js)

  • Server-side language redirect: a route on / reads Accept-Language, picks fr or en (default fr if absent/unrecognized), and responds res.redirect(301, '/fr/') or /en/'. Placed before the static/catch-all middleware. Replaces the client-side <Navigate> in RootRedirect.
  • Real 404s: the catch-all is given the list of routes that were actually prerendered (generated at build time — see "Route manifest" below). A request path not in that list gets res.status(404).sendFile(<prerendered 404 page>) instead of an implicit 200.
  • Compression: add the compression npm package (pure JS, standard Express middleware), mounted early in src/app.js, before express.static.
  • Cache-Control: express.static(frontendDist, { setHeaders }) sets Cache-Control: public, max-age=31536000, immutable for hashed asset paths (Vite's /assets/*.js, /assets/*.css) and Cache-Control: no-cache for any index.html response, so redeploys are picked up immediately while assets cache aggressively.

Route manifest

vite-react-ssg's build produces a known, fixed list of output directories. A small script (frontend/scripts/generateRouteManifest.js, run as part of npm run build) writes frontend/dist/route-manifest.json — a flat array of every valid path — consumed by both the sitemap generator and src/app.js's 404 check.

Sitemap generation

frontend/public/sitemap.xml (static, 2 URLs) is replaced by a build step (frontend/scripts/generateSitemap.js, runs after the SSG build, reads route-manifest.json) that writes frontend/dist/sitemap.xml with one <url> entry per prerendered route and <lastmod> set to the build timestamp. express.static serves it from dist like any other static file. robots.txt is unchanged (already correct).

Programmatic conversion-pair landing pages

Scope: 30 curated pairs, verified against the registry

Selected for realistic search intent and confirmed present in src/converters/*.js (no page is created for an unregistered pair):

Image (8): jpg→png, png→jpg, png→webp, webp→jpg, jpg→webp, gif→png, png→pdf, jpg→pdf

HEIC (2): heic→jpg, heic→png

Favicon/ICO (2): png→ico, ico→png

Document (8): pdf→docx, docx→pdf, pdf→txt, pdf→html, csv→xlsx, xlsx→csv, csv→pdf, xlsx→pdf

Video (5): mov→mp4, mkv→mp4, avi→mp4, webm→mp4, mp4→webm

Audio (5): wav→mp3, mp3→wav, m4a→mp3, flac→mp3, ogg→mp3

This is exactly at the quality-gate warning threshold (30 pages) — each page needs genuinely differentiated (60%+ unique) content, not templated text with only format names swapped in.

Data model

frontend/src/data/conversionPages.js — one entry per pair:

{
  pairId: 'pdf-to-docx',
  sourceFormat: 'pdf',
  targetFormat: 'docx',
  family: 'document',
  slugs: { fr: 'convertir-pdf-en-word', en: 'convert-pdf-to-word' },
  content: {
    fr: { title, description, intro, benefits: [...], steps: [...] },
    en: { title, description, intro, benefits: [...], steps: [...] },
  },
}

This single table drives: the prerendered route list, hreflang alternates (each language entry links to the other via slugs), the sitemap, and the page component itself.

Routing

/:lang/:slug pages are added alongside the existing /:lang/ home route, resolved against conversionPages.js by matching slugs[lang]. Localized slugs are used (e.g. /fr/convertir-pdf-en-word vs. /en/convert-pdf-to-word) rather than a shared slug across languages, since localized slugs read naturally and match local-language query patterns; the data model's slugs map keeps hreflang correct despite the differing paths.

Content

Each page reuses the existing conversion tool UI with source/target format pre-selected from the route, plus a real intro paragraph, a benefits list, and a short step-by-step guide. This copy is authored (fr + en) as part of implementation — it is not templated or auto-generated from format names, per the quality-gate uniqueness requirement.

Testing plan

  • Prerender output check: a script/test reads each expected dist/<route>/index.html and asserts (via string/regex checks) the presence and correctness of <title>, meta description, canonical, OG tags, hreflang alternates, and JSON-LD — this is what actually proves the SSR-vs-useEffect problem is fixed, not merely assumed fixed.
  • Express tests (Vitest, using .env.local per CLAUDE.md):
    • / → 301 to /fr/ or /en/ depending on Accept-Language.
    • Unknown path → 404 (not the current implicit 200).
    • Known route → 200, Cache-Control: no-cache on HTML, public, max-age=31536000, immutable on hashed assets.
    • Response is compressed when the client advertises support.
  • Sitemap test: generated sitemap.xml contains exactly one <url> per manifest entry, each with a valid <lastmod>.
  • Functional non-regression: the full existing Vitest suite, plus a manual browser pass (upload → convert → download) for one pair per family (image, document, audio, video), since the SSG migration changes the routing/render entry point (main.jsx).
  • Raw-HTML crawler simulation: curl (no JS execution) against /fr/, /en/, and one conversion-pair page, confirming SEO tags are present in the response body as received — distinct from a browser check, which always executes JS regardless of whether prerendering worked.

Risks / open questions

  • vite-react-ssg compatibility with react-router-dom v7 and React 19 (both current dependencies) needs to be confirmed against the installed versions during implementation — not assumed from documentation alone.
  • Adding vite-react-ssg and react-helmet-async to both package.json files, per the existing o2switch constraint that only root's node_modules is available at runtime on the server.
  • Content authoring for 60 pages (30 pairs × 2 languages) is a real writing task, not a mechanical one — likely the largest time cost in this project.