diff --git a/docs/superpowers/specs/2026-08-02-seo-fixes-design.md b/docs/superpowers/specs/2026-08-02-seo-fixes-design.md new file mode 100644 index 0000000..eb5f3d2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-seo-fixes-design.md @@ -0,0 +1,224 @@ +# 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 `` + (`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/*.js` → `registry.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//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 ``; 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 `` 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()` 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 +`` entry per prerendered route and `` 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: + +```js +{ + 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//index.html` and asserts (via string/regex checks) the + presence and correctness of ``, 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.