Skip to content

How to Generate Static Pages with React Router and Shared Layouts

In a community I participate in, a developer recently asked: “How do I use React Router to generate pages that share a layout at build time? I want the SEO benefits of static files but the DX of React Router’s nested routes.”

This is a common pain point. By default, React Router is a Client-Side Routing (CSR) library. To generate pages at build time (Static Site Generation or SSG), you need to bridge the gap between your route definitions and a build script that outputs HTML files.

The Immediate Fix: The “Framework” Approach

Section titled “The Immediate Fix: The “Framework” Approach”

The most robust way to achieve build-time generation with React Router today is to use React Router v7 (the successor to v6 and Remix). It allows you to define a layout once and “prerender” specific paths into static HTML files during the build process.

app/routes.ts
// React Router v7 / Remix - illustrative example
import { type RouteConfig, route, layout } from "@react-router/dev/routes";
export default [
// Shared Layout wrapper
layout("layouts/MainLayout.tsx", [
route("about", "routes/About.tsx"),
route("contact", "routes/Contact.tsx"),
]),
] satisfies RouteConfig;
// react-router.config.ts
export default {
// This triggers build-time generation (SSG)
async prerender() {
return ["/about", "/contact"];
},
};

To generate pages at build time with shared layouts, you must solve two problems: Nested Routing (the layout) and Static Generation (the build step).

Section titled “Solution 1: React Router v7 / Remix (Recommended)”

React Router v7 merged with the Remix framework. It uses the Outlet component to handle shared layouts and a prerender configuration to identify which routes should be turned into HTML at build time.

Version: React Router v7 / Node 20+

  1. Define the Layout: Create a file (e.g., layouts/MainLayout.tsx) that uses the <Outlet /> component. This component acts as a placeholder for child routes.
  2. Configure Routes: Use the layout() helper in your route configuration to wrap specific routes.
  3. Enable Prerendering: In your react-router.config.ts, export a prerender function. During npm run build, the library will spin up a server, crawl these paths, and save the resulting HTML to your dist folder.

Why this works: It keeps your code DRY (Don’t Repeat Yourself). The MainLayout is rendered into every static HTML file generated, ensuring that users see the header/footer immediately upon page load without waiting for JavaScript.

Solution 2: Vite + Vite-Plugin-Ssg (For Standard React Router v6)

Section titled “Solution 2: Vite + Vite-Plugin-Ssg (For Standard React Router v6)”

If you are using a standard Vite + React Router v6 setup and cannot migrate to the full framework model, you can use vite-plugin-ssg or vite-prerender.

Version: React 18, React Router v6.x, Vite 5

main.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Layout from "./Layout";
import Home from "./Home";
export const routes = [
{
path: "/",
element: <Layout />, // Your shared layout
children: [
{ path: "home", element: <Home /> },
{ path: "about", element: <About /> },
],
},
];
// For SSG, you often export the router or routes for a build script
export const router = createBrowserRouter(routes);

Implementation Steps:

  1. Shared Layout: Use the <Outlet /> inside your Layout component.
  2. Build Script: Use a plugin like vite-plugin-ssg. It hooks into the Vite build process, renders your React tree to a string using react-dom/server, and writes physical .html files for every route you provide.

Why this works: It intercepts the build process. Instead of delivering a single index.html with a blank <div>, it generates about.html and home.html containing the full markup of your layout and the specific page content.


  • Hydration Mismatches: If your layout uses logic that depends on the browser (like window.innerWidth), the static HTML generated at build time won’t match the first render on the client. This causes a “Hydration Error.” Always wrap browser-only logic in useEffect.
  • Dynamic Routes: Generating a page at build time for a route like /blog/:id requires you to provide a list of all possible IDs to the build script. If you have 10,000 blog posts, your build time will increase significantly.
  • Context Providers: Ensure your UserProvider or ThemeProvider wraps the Outlet or the entire RouterProvider so that the static state is captured correctly during the build-time render.

1. Is React Router v7 different from Remix? As of late 2024, they are the same thing. The Remix team merged the projects to simplify the ecosystem. If you want “build-time” pages, using the React Router v7 framework features is the official path forward.

2. Does this eliminate the need for a server? Yes. Once you generate these pages at build time, you can host the dist or build folder on any static host like Netlify, Vercel, or S3/CloudFront. You only need a server if you decide to use Server-Side Rendering (SSR) instead of SSG.

3. Will my shared layout re-render on every page change? When the user navigates between static pages after the initial load, React Router takes over as a Single Page Application (SPA). Because you are using a shared layout with <Outlet />, React is smart enough to persist the layout component and only re-render the changing child content, preserving state like scroll position or video playback in the sidebar.