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.
This commit is contained in:
2026-08-02 09:51:40 +02:00
parent afb0fed650
commit 9d30ff4c74
@@ -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 `<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/*.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/<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:
```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/<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.