Migrating a Static Next.js Blog to React Router 8 (Prerender)

I moved a blog built with Next.js 13 static export (output export) to React Router 8 framework mode and React 19. Here is how I kept URLs, meta tags, and sitemaps unchanged while building a static site with prerender, and the problems I ran into.

Why I moved from Next.js to React Router 8

This blog was originally built with Next.js 13's static export (output: 'export'). A static export builds every page as an HTML file ahead of time, with no server involved. I served that HTML from Cloudflare Pages.

The next.config.js was this simple:

const {withContentlayer} = require("next-contentlayer");

const nextConfig = {
  reactStrictMode: true,
  output: 'export',
  pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"]
};

module.exports = withContentlayer(nextConfig);

The problem was that even though I used no server features at all, a lot of the project was tied to Next.js. I was stuck on Next 13.4 and React 18, and Contentlayer, which converts my posts from MDX, was wired into Next through next-contentlayer. Meta tags (next-seo) and sitemaps (next-sitemap) were Next-specific packages too.

So I decided to move to React Router 8's framework mode, which supports static builds (prerender) out of the box. Framework mode uses React Router as a Vite-based framework; it is where the old Remix was merged into. I had one goal:

Change only the internal framework and keep everything visible from the outside (URLs, meta tags, sitemaps, UI) exactly the same.

That way I would not lose the URLs and ranking signals already in search engines.

Prerender setup: every page as HTML, no server

To build a static site with React Router, you turn off server rendering with ssr: false in react-router.config.ts and tell prerender which paths to build1.

import type {Config} from "@react-router/dev/config";

export default {
  appDirectory: "src/app",
  buildDirectory: "build",
  ssr: false,
  prerender: ({getStaticPaths}) => [...getStaticPaths(), "/404", "/en/404", "/ja/404"],
} satisfies Config;

getStaticPaths() returns every static path in the route config. As I explain below, this blog lists every path explicitly, so that alone produces every page. /404, /en/404, and /ja/404 are the per-language 404 pages.

Loaders run only at build time

React Router routes get their data from a function called loader. Normally it runs on the server on every request, but with ssr: false + prerender it runs only once, at build time. The result is stored in two places:

  • Inside the page HTML (used on the first visit)
  • A <path>.data file (when you navigate via a link inside the site, the browser only fetches this file)

As long as the full post data is only touched inside loaders, the browser JavaScript only contains the data that page needs. On the other hand, if a UI component imports the full post list, every post ends up in the browser bundle, so watch out for that.

One trap is the URL the loader receives. When the .data files for navigation are generated, the request URL comes in as /x.data (the home page is /_.data). So I wrote a helper that extracts the language and path from the URL.

export function requestPath(request: Request): [Locale, string[]] {
  const pathname = new URL(request.url).pathname.replace(/\.data$/, '').replace(/^\/_$/, '/');
  const [locale, rest] = splitLocalePath(pathname);
  return [locale, rest.split('/').filter(Boolean).map(decodeURIComponent)];
}

Recreating the Next Metadata API with meta functions

In Next.js, when generateMetadata returns an object, Next builds the title, description, canonical, og tags, and even Twitter cards for you. React Router has no such feature; a route's meta function has to return the tag list itself.

Even one or two different tags can affect search results, so I wrote a conversion function, seoMeta, that produces the same tags as the HTML Next used to generate. I moved the metadata objects I had written for Next into the loaders almost unchanged, and meta turns them into tags with this function.

// Post page route
export function loader({request}: LoaderFunctionArgs) {
  const [locale, [slug]] = requestPath(request);
  const found = postData[locale].allPostsMap.get(slug);
  // ...
  const metadata: Metadata = {
    title: post.title,
    description: post.description,
    keywords: post.tags,
    alternates: {canonical: `${SITE_URL}${post.url}`, languages: /* hreflang */},
    openGraph: {type: 'article', title: post.title, /* ... */},
  };
  return {locale, metadata, post, jsonLd};
}

export const meta: MetaFunction<typeof loader> = ({loaderData}) => {
  if (!loaderData) return [];
  const tags = seoMeta(loaderData.locale, loaderData.metadata);
  if (loaderData.jsonLd) tags.push({'script:ld+json': loaderData.jsonLd});
  return tags;
};

seoMeta mimics what Next did. It appends | Snc Studio to the title, and if there is openGraph, it also creates twitter:card. The structured data (JSON-LD) I used to add with next-seo's ArticleJsonLd became a script:ld+json meta entry.

After migrating, it is a good idea to compare the <head> of the old build output with the new HTML to make sure no tags are missing. The sitemaps (next-sitemap) also became resource routes (routes that return a file instead of HTML) that output XML in the same format.

Why I registered every path instead of :slug

In React Router you would normally register post pages with a single dynamic path like :slug. This blog instead reads the post data in src/app/routes.ts and registers every path one by one, registering the same route module many times with different ids.

function localeRoutes(locale: Locale): RouteConfigEntry[] {
  const prefix = locale === DEFAULT_LOCALE ? '' : `${locale}/`;
  const {allPostsMap, allPagedPostsMap, pagedCategoryPostMap, pagedTagPostMap} = postData[locale];
  const routes: RouteConfigEntry[] = [];
  const add = (path: string, file: string) => {
    // ...
    routes.push(route(`${prefix}${path}`, file, {id: `${locale}:${path}`}));
  };

  for (const slug of allPostsMap.keys()) add(slug, 'routes/post.tsx');
  // 카테고리·태그·페이지 번호 경로도 같은 방식으로 추가
  routes.push(route(`${prefix}*`, 'routes/not-found.tsx', {id: `${locale}:not-found`}));
  return routes;
}

The reason is the 404 page. When someone visits a URL that does not exist, Cloudflare Pages serves 404.html. React in the browser then matches routes again for that URL (hydration), and if :slug exists, any URL matches the post page, so the 404 screen turns into a "post not found" screen.

With every path listed explicitly, an unknown URL only ever matches the * at the end (a splat route that matches everything else). So the prebuilt 404 screen stays as it is. Adding /404 to prerender is what turns this splat route into HTML.

There is a downside, though. routes.ts is only read when the dev server starts, so whenever I add a new post or a new category or tag appears, I have to restart the dev server.

Keeping URLs: x/index.html to x.html

React Router's prerender writes /x as x/index.html. Cloudflare Pages, however, redirects /x to /x/ in that case. Next.js wrote x.html, so the existing URLs had no trailing /.

So a post-build script moves x/index.html to x.html. This part is long, so I wrote it up separately in How to Stop Cloudflare Pages Trailing Slash Redirects.

The build command in package.json became:

"build": "bun scripts/contentlayer.ts && react-router build && bun scripts/postbuild.ts",

Using Contentlayer without Next

Contentlayer is a tool that converts MDX posts into JSON and JavaScript. In Next.js, next-contentlayer hooked it into the build, but now I had to run it myself. I ran into three problems here.

Converting with a script instead of the CLI

The Contentlayer CLI (contentlayer build) converts successfully, but on recent Node versions it exits with an error related to process.exitCode and reports failure. So I ran what the CLI's build command does directly in scripts/contentlayer.ts.

process.env.NODE_ENV = 'production';

import * as core from "@contentlayer/core";
import {pipe, T} from "@contentlayer/utils/effect";

const generate = () => pipe(
  core.getConfig({configPath: undefined}),
  T.chain(config => core.generateDotpkg({config, verbose: false})),
  T.tap(core.logGenerateInfo),
  core.runMain({tracingServiceName: 'contentlayer-cli', verbose: false}),
);

await generate();

During development, the --watch option makes it watch src/posts and convert again whenever a post changes.

Converting in development mode breaks rendering on React 19

The process.env.NODE_ENV = 'production' at the top matters. mdx-bundler, which Contentlayer uses to bundle MDX, includes React's development jsx-runtime inside the MDX code unless NODE_ENV is production. That code does not match the React 19 production build, so post bodies failed to render.

The production output works fine on the dev server too, so I always force production.

Vite cannot read import assert

The .contentlayer/generated/index.mjs that Contentlayer generates loads JSON with import ... assert {type: 'json'}. That is the old syntax, now replaced by with {type: 'json'}, and Vite could not read it. So I skip index.mjs and import the JSON file directly. Only the types come from what Contentlayer generated.

import type {Post} from "@/contentlayer/generated";
import allPostsJson from "../../.contentlayer/generated/Post/_index.json";

const allPosts = allPostsJson as unknown as Post[];

I used a relative path because routes.ts reads this file too. routes.ts runs before Vite processes anything, so path aliases like @/ do not work there.

Other changes

  • MDX rendering: I wrote my own version of useMDXComponent from next-contentlayer/hooks as a small function of about 20 lines with the same implementation. It runs the code string Contentlayer generated with new Function and pulls out a React component.
  • Link buttons: Places that wrapped a button in Next's <Link legacyBehavior> now use a LinkButton component that adds href and click navigation to the button, producing the same DOM.
  • Root data: Shared data such as the sidebar's category and tag counts is loaded once in the root loader, and shouldRevalidate returns false so it is not reloaded on every navigation.
  • Dark mode: Despite its name, next-themes works without Next, so I kept it.

The removed packages include next, next-contentlayer, next-seo, next-sitemap, @next/mdx, and eslint-config-next, and React went from 18 to 19.

After the migration

As intended, almost nothing visible from the outside changed. URLs, meta tags, and sitemap formats match what they were before, so the URLs already registered in search engines keep working. Internally, all the Next-specific packages are gone and the project is now Vite-based and simpler.

In short, if your static blog does not use server features, React Router 8's ssr: false + prerender is enough to migrate. I would check these three things up front, though:

  • Meta tags are not generated for you, so compare the <head> of the HTML before and after the migration yourself.
  • If your host decides the trailing / based on where index.html is, like Cloudflare Pages, check the file structure.
  • Visit an unknown URL directly to make sure the 404 page does not turn into a different screen.

I wrote up automatic language redirects in Redirect Visitors by Browser Language with Cloudflare Pages Functions.

References

Footnotes

  1. https://reactrouter.com/how-to/pre-rendering ↩

2026 © snc studio·Privacy Policy