# SEO Fixes and Prerendering Migration Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make every route Convert exposes fully crawlable (correct title/meta/canonical/hreflang/JSON-LD in the raw HTML, real HTTP status codes) and add 30 curated conversion-pair landing pages to capture long-tail search traffic. **Architecture:** Migrate the frontend from pure client-side rendering to build-time prerendering with `vite-react-ssg` (each route rendered once at build to a static `index.html`, then hydrated normally), fix Express's redirect/404/caching behavior, and add a data-driven set of conversion-pair pages reusing the existing upload/convert UI. **Tech Stack:** React 19, react-router-dom (downgraded to v6.x — see Task 4), `vite-react-ssg` 0.9.2, `react-helmet-async` (transitive dependency of vite-react-ssg, consumed via its re-exported `Head` component), Express 5, `compression`. ## Global Constraints - Every runtime package imported by `frontend/src/**` must be added to **both** `frontend/package.json` and root `package.json` dependencies (o2switch's nodevenv only installs root's `node_modules`; verified in `CLAUDE.md`). Packages only ever invoked as a CLI/build tool (like `vite`, `@vitejs/plugin-react` today) can stay in root **devDependencies**, matching the existing pattern. - Don't guess package APIs or versions — every dependency version and API shape referenced in this plan (`vite-react-ssg@0.9.2`, `compression@1.8.1`, `react-router-dom@6.30.4`, the `ViteReactSSG`/`Head`/`ssgOptions` API) was confirmed against the published README/npm registry during planning, not assumed. - Local test runs must use `.env.local` values as inline env vars, never `.env` (production creds) — per `CLAUDE.md`: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run`. - No conversion-pair landing page is created for a pair that isn't actually registered in `src/converters/*.js` — every pair used in this plan was verified against the registry during planning. --- ### Task 1: Compression, cache-control headers, and a configurable frontend dist path **Files:** - Modify: `src/config.js` - Modify: `src/app.js:1-10` (imports), `src/app.js:251-255` (static serving) - Modify: `package.json` (root) — add `compression` - Test: `test/staticServing.test.js` (new) **Interfaces:** - Produces: `config.frontendDist` (string, absolute path) — consumed by Task 3. - [ ] **Step 1: Add `compression` to root `package.json` dependencies** In `package.json`, add to `"dependencies"` (alphabetical, matching existing style): ```json "compression": "^1.8.1", ``` Run: `npm install` - [ ] **Step 2: Add `frontendDist` to `src/config.js`** `src/config.js` currently has no `path` import. Add it and a new config field: ```js import 'dotenv/config'; import path from 'node:path'; const REQUIRED_VARS = ['STORAGE_DIR', 'DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']; export function loadConfig() { const missing = REQUIRED_VARS.filter((key) => !process.env[key]); if (missing.length > 0) { throw new Error(`Missing required environment variables: ${missing.join(', ')}`); } return { port: Number(process.env.PORT ?? 3000), storageDir: process.env.STORAGE_DIR, frontendDist: path.join(import.meta.dirname, '..', 'frontend', 'dist'), db: { host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, }, maxFileSizeMb: Number(process.env.MAX_FILE_SIZE_MB ?? 100), retentionHours: Number(process.env.RETENTION_HOURS ?? 1), workerPollIntervalMs: Number(process.env.WORKER_POLL_INTERVAL_MS ?? 1500), workerConcurrency: Number(process.env.WORKER_CONCURRENCY ?? 3), ebookJobTimeoutMs: Number(process.env.EBOOK_JOB_TIMEOUT_MS ?? 180000), videoJobTimeoutMs: Number(process.env.VIDEO_JOB_TIMEOUT_MS ?? 300000), rateLimitMaxJobs: Number(process.env.RATE_LIMIT_MAX_JOBS ?? 20), rateLimitWindowMinutes: Number(process.env.RATE_LIMIT_WINDOW_MINUTES ?? 10), }; } ``` This mirrors exactly what `src/app.js:251` already computes inline — moving it to config makes it overridable in tests the same way `storageDir` already is (see `test/api/jobs.test.js:18-24`, which does `{ ...loadConfig(), storageDir: }`). - [ ] **Step 3: Write the failing test for compression + cache headers** Create `test/staticServing.test.js`: ```js import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { createApp } from '../src/app.js'; import { getPrismaClient, closePrismaClient } from '../src/db.js'; import { loadConfig } from '../src/config.js'; import { ensureStorageDirs } from '../src/storage.js'; let app; let prisma; let config; let frontendDist; beforeAll(async () => { frontendDist = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-dist-')); await fs.mkdir(path.join(frontendDist, 'fr'), { recursive: true }); await fs.mkdir(path.join(frontendDist, 'assets'), { recursive: true }); await fs.writeFile(path.join(frontendDist, 'fr', 'index.html'), 'fr home'); await fs.writeFile(path.join(frontendDist, 'assets', 'app.abc123.js'), 'console.log(1)'); config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-api-')), frontendDist, }; await ensureStorageDirs(config); prisma = getPrismaClient(config); app = createApp(config, prisma); }); afterAll(async () => { await closePrismaClient(); await fs.rm(config.storageDir, { recursive: true, force: true }); await fs.rm(frontendDist, { recursive: true, force: true }); }); describe('static asset serving', () => { it('sends HTML pages with Cache-Control: no-cache', async () => { const response = await request(app).get('/fr/'); expect(response.status).toBe(200); expect(response.headers['cache-control']).toBe('no-cache'); }); it('sends hashed assets with a long immutable Cache-Control', async () => { const response = await request(app).get('/assets/app.abc123.js'); expect(response.status).toBe(200); expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable'); }); it('compresses responses when the client accepts it', async () => { const response = await request(app).get('/fr/').set('Accept-Encoding', 'gzip'); expect(response.headers['content-encoding']).toBe('gzip'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` Expected: FAIL — no `Cache-Control` header set, no compression, and `frontendDist` fixture isn't wired into `createApp` yet (current code hardcodes the path at `src/app.js:251`). - [ ] **Step 4: Wire compression and cache headers into `src/app.js`** At the top of `src/app.js`, add the import (alphabetical with the existing block): ```js import compression from 'compression'; ``` Inside `createApp`, right after `app.set('trust proxy', '1');` (currently line 67), add: ```js app.use(compression()); ``` Replace `src/app.js:251-255`: ```js const frontendDist = path.join(import.meta.dirname, '..', 'frontend', 'dist'); app.use(express.static(frontendDist)); app.get(/^\/(?!api\/).*/, (req, res) => { res.sendFile(path.join(frontendDist, 'index.html')); }); ``` with: ```js const frontendDist = config.frontendDist; app.use( express.static(frontendDist, { setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache'); } else if (filePath.includes(`${path.sep}assets${path.sep}`)) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } }, }) ); ``` (The 404 catch-all that currently follows this block is replaced in Task 3 — leave the existing `app.get(/^\/(?!api\/).*/, ...)` block in place for now, Task 3 rewrites it.) - [ ] **Step 5: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` Expected: PASS - [ ] **Step 6: Run the full existing suite to confirm no regression** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run` Expected: PASS (aside from the two pre-existing, unrelated failures documented in `CLAUDE.md`: `test/cleanup.test.js` and `test/jobs/jobRepository.test.js`) - [ ] **Step 7: Commit** ```bash git add src/config.js src/app.js package.json package-lock.json test/staticServing.test.js git commit -m "feat(seo): add compression, cache-control headers, configurable frontend dist path" ``` --- ### Task 2: Server-side language redirect for `/` **Files:** - Modify: `src/app.js` (add redirect route) - Test: `test/staticServing.test.js` (extend) **Interfaces:** - Consumes: nothing new. - Produces: `GET /` now returns a 301, never falls through to `express.static`. - [ ] **Step 1: Write the failing test** Add to `test/staticServing.test.js`, inside a new `describe` block: ```js describe('root redirect', () => { it('redirects to /en/ when Accept-Language prefers English', async () => { const response = await request(app).get('/').set('Accept-Language', 'en-US,en;q=0.9'); expect(response.status).toBe(301); expect(response.headers.location).toBe('/en/'); }); it('redirects to /fr/ when Accept-Language prefers French', async () => { const response = await request(app).get('/').set('Accept-Language', 'fr-FR,fr;q=0.9,en;q=0.5'); expect(response.status).toBe(301); expect(response.headers.location).toBe('/fr/'); }); it('defaults to /fr/ when Accept-Language is absent', async () => { const response = await request(app).get('/'); expect(response.status).toBe(301); expect(response.headers.location).toBe('/fr/'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` Expected: FAIL — `/` currently falls through to the static catch-all and returns 200, not 301. - [ ] **Step 3: Add the redirect route** In `src/app.js`, add this route before `app.use(express.static(...))` (i.e., right after the `app.use(compression())` line added in Task 1, and before the API routes is fine too — place it right after `app.use(compression())`): ```js app.get('/', (req, res) => { const acceptLanguage = req.headers['accept-language'] ?? ''; const primaryTag = acceptLanguage.split(',')[0]?.split(';')[0]?.split('-')[0]?.trim().toLowerCase(); const lang = primaryTag === 'en' ? 'en' : 'fr'; res.redirect(301, `/${lang}/`); }); ``` This mirrors the exact same "en → en, everything else → fr" rule the current client-side `RootRedirect` uses (`frontend/src/App.jsx:11`: `navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr'`), so behavior is unchanged for users — only *when* the redirect happens changes (now before any HTML ships). - [ ] **Step 4: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/app.js test/staticServing.test.js git commit -m "feat(seo): redirect / to /fr/ or /en/ server-side based on Accept-Language" ``` --- ### Task 3: Real HTTP 404 for unmatched routes **Files:** - Modify: `src/app.js:251-255` area (the catch-all after `express.static`) - Test: `test/staticServing.test.js` (extend) **Interfaces:** - Consumes: nothing new (relies on `express.static`'s own file-existence check, not a manifest). - Produces: any request under a non-`/api` path with no matching file in `frontendDist` gets `404` with the prerendered not-found page's HTML. - [ ] **Step 1: Write the failing test** Add to the fixture setup in `test/staticServing.test.js`'s `beforeAll` (after the `fr` dir/file writes): ```js await fs.mkdir(path.join(frontendDist, '404'), { recursive: true }); await fs.writeFile(path.join(frontendDist, '404', 'index.html'), 'not found'); ``` Add a new `describe` block: ```js describe('404 handling', () => { it('returns a real 404 status for an unknown path', async () => { const response = await request(app).get('/this-page-does-not-exist/'); expect(response.status).toBe(404); expect(response.text).toContain('not found'); }); it('still serves a known route with 200', async () => { const response = await request(app).get('/fr/'); expect(response.status).toBe(200); expect(response.text).toContain('fr home'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` Expected: FAIL — the current catch-all (`src/app.js:253-255`) always `sendFile`s `index.html` (which doesn't exist in this fixture at the dist root at all, so it would currently 404 with an ENOENT-style Express error, not the intended not-found page — either way, today's code doesn't serve `404/index.html` with status 404). - [ ] **Step 3: Replace the catch-all** Replace the remaining catch-all in `src/app.js` (right after the `express.static` block from Task 1): ```js app.get(/^\/(?!api\/).*/, (req, res) => { res.sendFile(path.join(frontendDist, 'index.html')); }); ``` with: ```js app.get(/^\/(?!api\/).*/, (req, res) => { res.status(404).sendFile(path.join(frontendDist, '404', 'index.html')); }); ``` This handler only runs when `express.static` (mounted just before it) found no matching file for the request — that's `express.static`'s standard behavior (it calls `next()` on no match instead of responding), so a request for a real prerendered route never reaches this line at all; it serves straight from the static middleware with a 200. - [ ] **Step 4: Run test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/staticServing.test.js` Expected: PASS - [ ] **Step 5: Run the full suite** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run` Expected: PASS (aside from the two documented pre-existing failures) - [ ] **Step 6: Commit** ```bash git add src/app.js test/staticServing.test.js git commit -m "feat(seo): return a real 404 status for unmatched routes" ``` --- ### Task 4: Downgrade react-router-dom to v6 **Files:** - Modify: `package.json` (root), `frontend/package.json` **Interfaces:** - Produces: `react-router-dom@6.30.4` available to both the root and `frontend` install trees — required because `vite-react-ssg@0.9.2` declares a peer dependency on `react-router-dom@^6.14.1` and hard-conflicts (`ERESOLVE`, confirmed via `npm install vite-react-ssg --dry-run` during planning) with the currently installed `^7.18.2`. `react-router-dom@6.30.4`'s own peer dependencies (`react`/`react-dom` `>=16.8`) are satisfied by React 19, and a combined dry-run of `react-router-dom@6.30.4` + `vite-react-ssg` resolved with zero conflicts (also confirmed during planning). - This project's actual router usage (`BrowserRouter`, `Routes`, `Route`, `Navigate`, `useParams`, `Outlet`, `Link` — see `frontend/src/App.jsx`, `frontend/src/components/Layout.jsx`, `frontend/src/pages/NotFound.jsx`) is basic declarative routing with no v7-only APIs, so no application code changes are required for this downgrade — only the dependency version. - [ ] **Step 1: Update both package.json files** In `package.json` (root), change: ```json "react-router-dom": "^7.18.2", ``` to: ```json "react-router-dom": "^6.30.4", ``` In `frontend/package.json`, change: ```json "react-router-dom": "^7.18.2" ``` to: ```json "react-router-dom": "^6.30.4" ``` - [ ] **Step 2: Install in both locations** Run: `npm install` Run: `npm install --prefix frontend` - [ ] **Step 3: Confirm the frontend still builds** Run: `npm run build --prefix frontend` Expected: build succeeds with no errors referencing `react-router-dom`. - [ ] **Step 4: Manual smoke test** Run: `npm run dev --prefix frontend` (in the background) and, in a browser, visit the dev URL, confirm: the app loads, redirects to `/fr/` or `/en/`, the file upload/convert flow still works for at least one file, and the language switcher (if present in `Header.jsx`) still navigates correctly. Stop the dev server afterward. - [ ] **Step 5: Commit** ```bash git add package.json package-lock.json frontend/package.json frontend/package-lock.json git commit -m "chore(frontend): downgrade react-router-dom to v6 for vite-react-ssg compatibility" ``` --- ### Task 5: Migrate to build-time prerendering with vite-react-ssg This is the core architectural task. It replaces the CSR entry point with a prerendering-capable one, rewrites `SeoHead` to render synchronously (fixing the `useEffect`-never-runs-during-SSR problem identified in the spec), and adds the `/404` route needed by Task 3's fallback. **Files:** - Create: `frontend/src/data/routePaths.js` - Create: `frontend/src/data/conversionPages.js` (empty array for now — populated in Tasks 7-10) - Create: `frontend/src/routes.jsx` - Create: `frontend/src/RootRedirect.jsx` - Create: `frontend/src/components/LangLayout.jsx` - Modify: `frontend/src/main.jsx` - Modify: `frontend/src/App.jsx` — deleted, replaced by `routes.jsx` + `RootRedirect.jsx` + `LangLayout.jsx` - Modify: `frontend/src/components/SeoHead.jsx` - Modify: `frontend/src/pages/HomePage.jsx` (read `lang` from a prop instead of `useParams`) - Modify: `frontend/src/pages/NotFound.jsx` (add noindex head) - Modify: `frontend/vite.config.js` - Modify: `frontend/package.json`, `package.json` (root) — add `vite-react-ssg`, change build/dev scripts **Interfaces:** - Produces: `SUPPORTED_LANGS` (array `['fr', 'en']`) and `listAllRoutePaths()` (returns `string[]` of every content path, e.g. `['/fr', '/en']` today, growing as Tasks 7-10 add entries) from `frontend/src/data/routePaths.js` — consumed by `routes.jsx` and `vite.config.js`. - Produces: `SeoHead({ lang, path, title, description, jsonLd })` (all strings except `jsonLd`, an object or `null`) from `frontend/src/components/SeoHead.jsx` — consumed by `HomePage.jsx` (Task 5) and `ConversionPage.jsx` (Task 7). - [ ] **Step 1: Add `vite-react-ssg` to both package.json files** In `frontend/package.json`, add to `"dependencies"`: ```json "vite-react-ssg": "^0.9.2", ``` Change the `"scripts"` block from: ```json "dev": "vite", "build": "vite build", ``` to: ```json "dev": "vite-react-ssg dev", "build": "vite-react-ssg build", ``` In `package.json` (root), add to `"dependencies"` (this package is `import`ed at runtime by `frontend/src/main.jsx` and `SeoHead.jsx`, not just invoked as a CLI tool, so per the Global Constraints it belongs in dependencies, matching `react-router-dom`/`react-i18next`'s placement, not alongside `vite`/`@vitejs/plugin-react` in devDependencies): ```json "vite-react-ssg": "^0.9.2", ``` Run: `npm install --prefix frontend` Run: `npm install` - [ ] **Step 2: Create the route-paths data module** Create `frontend/src/data/conversionPages.js`: ```js export const conversionPages = []; ``` Create `frontend/src/data/routePaths.js`: ```js import { conversionPages } from './conversionPages.js'; export const SUPPORTED_LANGS = ['fr', 'en']; export function listAllRoutePaths() { const paths = []; for (const lang of SUPPORTED_LANGS) { paths.push(`/${lang}`); for (const page of conversionPages) { paths.push(`/${lang}/${page.slugs[lang]}`); } } return paths; } ``` - [ ] **Step 3: Extract `RootRedirect` and `LangLayout`** Create `frontend/src/RootRedirect.jsx`: ```jsx import { Navigate } from 'react-router-dom'; export function RootRedirect() { const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr'; return ; } ``` (Unreachable in production, where Express's Task 2 redirect always answers `/` first — kept as a client-side fallback for `vite-react-ssg dev`, which doesn't run through `src/app.js`.) Create `frontend/src/components/LangLayout.jsx`: ```jsx import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; import i18n from '../i18n.js'; import { Header } from './Header.jsx'; import { Footer } from './Footer.jsx'; import { useTheme } from '../hooks/useTheme.js'; import '../styles/layout.css'; export function LangLayout({ lang }) { const { theme, toggleTheme } = useTheme(); useEffect(() => { if (i18n.language !== lang) { i18n.changeLanguage(lang); } }, [lang]); return ( <>