ITUGUI

How to Manually Prerender a React App with a Node Script

6 min
ReactprerenderingSSRNode.jsVitestatic-site-generationJavaScript

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:

  1. Renders React routes on Node.js using react-dom/server
  2. Injects the rendered HTML into an HTML template
  3. Writes one index.html per 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.