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.
10 KiB
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 auseEffectinsidefrontend/src/components/SeoHead.jsx, so a crawler that doesn't execute JS sees only the static, generic tags baked intofrontend/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) returnsindex.htmlwith an implicit 200 for every unmatched route, including typos — a soft-404. - No
canonicaltag, incomplete OG/Twitter tags, no compression middleware, no explicitCache-Controlon static assets. frontend/public/sitemap.xmlis 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.xmlreflects 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/ rootpackage.json: addreact-helmet-asyncandvite-react-ssg(frontend needs both for local dev; root needsvite-react-ssgtoo, per the existing o2switch single-node_modulesconstraint documented inCLAUDE.md).frontend/src/main.jsx: replaced with aViteReactSSGentry (exports an app-creation function instead of callingcreateRoot().render()directly). Routes declared as a single config array consumed by both the prerender pass and client hydration.frontend/vite.config.js: addvite-react-ssgbuild config, listing every route to prerender (see route list below).frontend/src/components/SeoHead.jsx: rewritten on top ofreact-helmet-async's<Helmet>; addscanonical(currently missing) and makeshreflangalternates and JSON-LD render synchronously instead of viauseEffect. Also rendered fromNotFound.jsx(currently not called there), with anoindexmeta tag for the 404 page specifically.frontend/src/pages/HomePage.jsxand the new conversion-pair pages (below) consume the rewrittenSeoHead.
Express changes (src/app.js)
- Server-side language redirect: a route on
/readsAccept-Language, picksfroren(defaultfrif absent/unrecognized), and respondsres.redirect(301, '/fr/')or/en/'. Placed before the static/catch-all middleware. Replaces the client-side<Navigate>inRootRedirect. - 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
compressionnpm package (pure JS, standard Express middleware), mounted early insrc/app.js, beforeexpress.static. - Cache-Control:
express.static(frontendDist, { setHeaders })setsCache-Control: public, max-age=31536000, immutablefor hashed asset paths (Vite's/assets/*.js,/assets/*.css) andCache-Control: no-cachefor anyindex.htmlresponse, 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.htmland asserts (via string/regex checks) the presence and correctness of<title>, meta description,canonical, OG tags,hreflangalternates, and JSON-LD — this is what actually proves the SSR-vs-useEffectproblem is fixed, not merely assumed fixed. - Express tests (Vitest, using
.env.localperCLAUDE.md):/→ 301 to/fr/or/en/depending onAccept-Language.- Unknown path → 404 (not the current implicit 200).
- Known route → 200,
Cache-Control: no-cacheon HTML,public, max-age=31536000, immutableon hashed assets. - Response is compressed when the client advertises support.
- Sitemap test: generated
sitemap.xmlcontains 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-ssgcompatibility withreact-router-domv7 and React 19 (both current dependencies) needs to be confirmed against the installed versions during implementation — not assumed from documentation alone.- Adding
vite-react-ssgandreact-helmet-asyncto bothpackage.jsonfiles, per the existing o2switch constraint that only root'snode_modulesis 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.