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

225 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.