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