If your React app is mostly static pages (landing, docs, legal pages, marketing routes), prerendering can improve SEO and first paint without moving to a different framework.
In this post, I’ll show a manual setup where a script generates HTML files for a route list.
Install dependencies
npm i react-helmet
What “manual prerender” means
Manual prerendering is a build step that:
- Renders React routes on Node.js using
react-dom/server - Injects the rendered HTML into an HTML template
- Writes one
index.htmlper route
At runtime, your app is still a normal client-side React app and hydrates on load.
1. Create a server render entry
Create src/entry-server.jsx:
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Helmet } from 'react-helmet';
import { StaticRouter } from 'react-router-dom/server';
import App from './App';
export function render(url) {
const appHtml = renderToString(
<StaticRouter location={url}>
<App />
</StaticRouter>
);
const helmet = Helmet.renderStatic();
return { appHtml, helmet };
}
This is the function the prerender script will call for each route.
2. Add page metadata with Helmet
Inside route components, define title and meta tags:
import { Helmet } from 'react-helmet';
export default function AboutPage() {
return (
<>
<Helmet>
<title>About | My App</title>
<meta
name="description"
content="Learn more about our product and team."
/>
<link rel="canonical" href="https://example.com/about" />
</Helmet>
<main>...</main>
</>
);
}
3. Keep a route list to prerender
Create prerender-routes.json at project root:
[
"/",
"/about",
"/pricing",
"/contact"
]
For dynamic pages, generate this list from your CMS or content files before prerendering.
4. Add the prerender script
Create scripts/prerender.mjs:
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import routes from '../prerender-routes.json' with { type: 'json' };
import { render } from '../src/entry-server.jsx';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const distDir = path.join(projectRoot, 'dist');
const templatePath = path.join(distDir, 'index.html');
const template = await fs.readFile(templatePath, 'utf8');
for (const route of routes) {
const { appHtml, helmet } = render(route);
const html = template
.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`)
.replace('</head>', `${helmet.title.toString()}${helmet.meta.toString()}${helmet.link.toString()}</head>`);
const routePath = route === '/' ? '' : route.replace(/^\//, '');
const outDir = path.join(distDir, routePath);
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(path.join(outDir, 'index.html'), html, 'utf8');
console.log(`Prerendered: ${route}`);
}
5. Add build scripts in package.json
{
"scripts": {
"build": "vite build",
"prerender": "node scripts/prerender.mjs",
"build:static": "npm run build && npm run prerender"
}
}
Run:
npm run build:static
After this, each route will have its own HTML file in dist/<route>/index.html.
Common pitfalls
- Browser-only APIs (
window,document,localStorage) in server render paths will crash prerendering. - Data that depends on authenticated users should usually stay client-side.
- Route lists must be complete, otherwise missing pages won’t be prerendered.
- If your template already has a fixed
<title>or static meta tags, remove duplicates before injecting Helmet output.
When this approach is useful
- You already have a React app and want quick SEO/performance wins.
- You only need prerendering for known routes.
- You want full control over the build pipeline.
If your app needs heavy server data loading per request, full SSR frameworks are a better fit. But for static and content-heavy routes, this script-based approach works well and keeps your stack simple.