Every Gatsby migration conversation we have in 2026 starts the same way: the site works, the builds are slow or the plugins are unmaintained, and someone has been told to "move to Next.js." Next.js is a fine answer. It is not the only one, and for a particular shape of site it is the wrong one.
That shape: a Gatsby site that is mostly static pages, has one or two genuinely dynamic corners (a search page, a gated area, a form-heavy flow), and whose team wants plain React with a router instead of a framework with its own rendering model, caching semantics, and server/client component boundary. For that site, React Router v7 in framework mode — the project formerly shipped as Remix — is the lowest-surprise destination. You can prerender the static routes to real HTML, run the dynamic ones on a server or at the edge, and the mental model is "routes, loaders, actions," which is about four concepts in total.
This tutorial maps a Gatsby 5 site onto React Router v7 framework mode piece by piece: routing, data, images, metadata, prerendering, and the parts that do not map cleanly. It assumes you have read our Astro and Next.js App Router migration guides and want the third option on the table before committing.
Choosing between the three
Be honest about this before you write code, because the cost of picking wrong is a second migration.
| If your site is… | Go to |
|---|---|
| Content-heavy, little interactivity, Markdown/MDX source | Astro |
| Needs ISR, image CDN, a big ecosystem, or the org already standardised on it | Next.js |
| Mostly static but with real app-like routes, and you want plain React + fetch | React Router v7 |
| Fine as-is, just unmaintained plugins | Stay on Gatsby (here's how) |
React Router v7's specific advantages for ex-Gatsby teams: no GraphQL layer to replace with another abstraction (you just fetch), no server/client component rules to internalise, one routing concept rather than two, and a prerender story that gets you back to static HTML on a CDN. Its disadvantages: a smaller plugin ecosystem than Next, no built-in image optimisation, and you own more of the build plumbing.
1. Scaffold and understand the route model
npx create-react-router@latest staticcraft-web
cd staticcraft-web
npm run dev
You get a Vite project with app/root.tsx, app/routes.ts, and an app/routes/ directory. The critical difference from Gatsby: routes are declared, not discovered from the filesystem (unless you opt into the filesystem convention). app/routes.ts is a real TypeScript module, which means Gatsby's createPages — a programmatic loop over a data source — has a direct analogue.
Gatsby:
// gatsby-node.js
exports.createPages = async ({ graphql, actions }) => {
const { data } = await graphql(`{ allMarkdownRemark { nodes { fields { slug } } } }`);
data.allMarkdownRemark.nodes.forEach((node) => {
actions.createPage({
path: node.fields.slug,
component: require.resolve('./src/templates/post.js'),
context: { slug: node.fields.slug },
});
});
};
React Router v7:
// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('services', 'routes/services.tsx'),
route('tutorials', 'routes/tutorials.tsx'),
route('tutorials/:slug', 'routes/tutorial.tsx'),
route('contact', 'routes/contact.tsx'),
] satisfies RouteConfig;
One template file serves every post, with :slug as the parameter — the same one-template-many-pages idea, expressed as a route pattern instead of a build-time loop. The per-page context object Gatsby passed into your template becomes route params plus whatever the loader fetches.
2. Replace the GraphQL data layer with loaders
This is the part that scares people and shouldn't. Gatsby's page query:
export const query = graphql`
query($slug: String!) {
markdownRemark(fields: { slug: { eq: $slug } }) {
html
frontmatter { title date description }
}
}
`;
export default function PostTemplate({ data }) {
return <article dangerouslySetInnerHTML={{ __html: data.markdownRemark.html }} />;
}
The React Router equivalent in app/routes/tutorial.tsx:
import type { Route } from './+types/tutorial';
import { getPostBySlug } from '../lib/content.server';
export async function loader({ params }: Route.LoaderArgs) {
const post = await getPostBySlug(params.slug);
if (!post) throw new Response('Not Found', { status: 404 });
return { post };
}
export default function Tutorial({ loaderData }: Route.ComponentProps) {
const { post } = loaderData;
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
loader runs on the server (or at build time for prerendered routes), never in the browser bundle. Anything in a .server.ts module is stripped from the client build, so your CMS token stays server-side the same way gatsby-node.js secrets did.
Run npm run typecheck once and React Router generates the ./+types/* modules: loaderData is typed from your loader's return value with no codegen step to configure. Ex-Gatsby teams who fought with schema customization and typegen tend to find this the single biggest quality-of-life gain.
For a Markdown site, content.server.ts is small — and much less machinery than gatsby-source-filesystem plus gatsby-transformer-remark:
// app/lib/content.server.ts
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
const CONTENT_DIR = path.join(process.cwd(), 'content', 'tutorials');
export async function listPosts() {
const files = await fs.readdir(CONTENT_DIR);
const posts = await Promise.all(
files.filter((f) => f.endsWith('.md')).map(async (f) => {
const raw = await fs.readFile(path.join(CONTENT_DIR, f), 'utf8');
const { data } = matter(raw);
return { slug: f.replace(/\.md$/, ''), ...data } as PostMeta;
}),
);
return posts.sort((a, b) => (a.date < b.date ? 1 : -1));
}
export async function getPostBySlug(slug: string) {
const file = path.join(CONTENT_DIR, `${slug}.md`);
const raw = await fs.readFile(file, 'utf8').catch(() => null);
if (!raw) return null;
const { data, content } = matter(raw);
const html = String(
await unified().use(remarkParse).use(remarkRehype).use(rehypeStringify).process(content),
);
return { slug, html, ...data };
}
If your content lives in a CMS instead, the loader just calls the CMS API directly. The GraphQL node layer that Gatsby interposed between source and page disappears — and with it, the class of bugs where a source plugin's inferred schema changed shape because one entry had a null field.
3. Prerender so you keep static HTML
A Gatsby refugee's non-negotiable: the marketing pages must still be files on a CDN. React Router v7 does this with prerender in the Vite config.
// react-router.config.ts
import type { Config } from '@react-router/dev/config';
import { listPosts } from './app/lib/content.server';
export default {
ssr: true,
async prerender() {
const posts = await listPosts();
return [
'/',
'/services',
'/tutorials',
'/contact',
...posts.map((p) => `/tutorials/${p.slug}`),
];
},
} satisfies Config;
At build time each listed path is rendered to HTML (plus a .data file for client-side navigations) into build/client/. Routes you do not list are rendered on demand by the server build. That mixed mode is the thing that makes this attractive: no "static export" cliff where adding one dynamic route forces the entire site onto a server.
Set ssr: false as well if you want a pure static deploy with no server at all — the prerendered pages still work, but loaders for non-prerendered routes then run in the browser, which changes your secret-handling assumptions. Pick one and write the choice down in the README.
Verify the output the same way you verify a Gatsby build:
npm run build
find build/client -name 'index.html' | wc -l
grep -c '<h1' build/client/tutorials/some-post/index.html
If the HTML contains only a shell, your content is coming from a client-side fetch and you have quietly rebuilt a SPA — the exact failure mode described in our AI search legibility post.
4. Metadata: Head export becomes meta
Gatsby 5's Head export maps to React Router's meta export, with the same build-time guarantee.
export function meta({ data, location }: Route.MetaArgs) {
const siteUrl = 'https://www.example.com';
const canonical = `${siteUrl}${location.pathname}`;
return [
{ title: data.post.title },
{ name: 'description', content: data.post.description },
{ tagName: 'link', rel: 'canonical', href: canonical },
{ property: 'og:title', content: data.post.title },
{
'script:ld+json': {
'@context': 'https://schema.org',
'@type': 'Article',
headline: data.post.title,
datePublished: data.post.date,
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
},
},
];
}
The 'script:ld+json' key emits JSON-LD into the document head without dangerouslySetInnerHTML, which is a small but real upgrade over the Gatsby pattern.
5. Images: the one place you lose a plugin
There is no gatsby-plugin-image equivalent. GatsbyImage gave you AVIF/WebP derivatives, blur-up placeholders, and correct srcset for free; you now choose:
vite-imagetools— closest to the Gatsby experience for local images. Query-string transforms at build time, sharp under the hood, and it runs in the same Vite pipeline.- An image CDN (Cloudinary, imgix, Netlify Image CDN) — a URL-builder helper and done, at the cost of a dependency and a bill.
- Pre-generate derivatives in a script and write plain
<picture>elements — fine for a site with 40 images, miserable for 4,000.
With vite-imagetools:
import heroAvif from '../images/hero.jpg?w=800;1600&format=avif&as=srcset';
import heroJpg from '../images/hero.jpg?w=800;1600&format=jpeg&as=srcset';
export function Hero() {
return (
<picture>
<source type="image/avif" srcSet={heroAvif} sizes="(max-width: 768px) 100vw, 800px" />
<img src={heroJpg.split(' ')[0]} srcSet={heroJpg} width={800} height={450}
alt="" loading="eager" fetchPriority="high" decoding="async" />
</picture>
);
}
Always set width/height on the <img>. Gatsby's wrapper reserved layout space for you; nothing does that now, and the first thing teams notice after this migration is a CLS regression on image-heavy pages. Re-run your Core Web Vitals checks against the new build before cutover, not after.
6. Forms get simpler: actions replace functions
If you moved contact forms to serverless functions after Gatsby Cloud shut down, they collapse back into the route:
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const email = String(form.get('email') ?? '');
if (!email.includes('@')) return { ok: false, error: 'Enter a valid email address.' };
await sendToCrm({ email, message: String(form.get('message') ?? '') });
return { ok: true };
}
export default function Contact({ actionData }: Route.ComponentProps) {
return (
<Form method="post">
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
{actionData?.error && <p role="alert">{actionData.error}</p>}
{actionData?.ok && <p role="status">Thanks — we'll reply within one business day.</p>}
</Form>
);
}
Note that this works without JavaScript: <Form> degrades to a native form post. That is a genuine accessibility and reliability win over the fetch-and-setState pattern most Gatsby sites ship. A caveat: a prerendered, server-less deployment has nowhere to run the action, so contact routes must be non-prerendered (or keep using an external endpoint).
7. What does not map cleanly
- Gatsby themes and component shadowing. No equivalent. If you run many sites off one codebase, replace shadowing with an explicit component-registry prop or a workspace package per site — and budget real time for it.
gatsby-source-*plugins. Each becomes afetchin a loader. Usually less code; occasionally you re-implement pagination, rate-limit handling, and retries that the plugin hid. Audit which sources you actually query before estimating.- DSG / Deferred Static Generation. The nearest equivalent is leaving a route out of
prerenderand caching the server response at the CDN edge. Behaviour is similar, cache invalidation is yours to design. gatsby-plugin-sitemap/robots-txt. Write aroutes.tsentry returning aResponsewith XML, or emit the files in a post-build script. Twenty lines, but nobody remembers it until a crawl report comes back empty.- Redirects. Gatsby's
createRedirectcalls become host config (_redirects,netlify.toml,vercel.json). Export your existing redirect map first — this is exactly the URL-preservation work that decides whether traffic survives the cutover.
A migration order that works
- Scaffold the new app; port
root.tsx, global styles, header and footer. - Port one content route end to end — loader, template, meta, prerender — and diff its HTML against the Gatsby build.
- Port the content layer (
content.server.tsor CMS client) and the index/listing routes. - Port marketing pages, newest and highest-traffic first.
- Port forms and any dynamic routes; re-check security headers and CSP against the new asset origins.
- Port redirects, sitemap,
robots.txt, analytics, structured data. - Run both builds in CI; compare the URL inventory from each sitemap and fail on any missing path.
- Cut over behind edge rewrites, route by route, rather than in one jump — the strangler approach applies here unchanged.
Step 7 is the one teams skip and regret. A trivial script that diffs the set of URLs produced by the old and new builds catches the missing-tag-pages and missing-pagination bugs that otherwise surface as a traffic cliff three weeks later.
Is it worth it?
For a content-only site: probably not — Astro is less work and ships less JavaScript. For a site that is half marketing and half application, where the team's skill is React and the appetite for framework-specific abstractions is low, React Router v7 is the most boring migration of the three, and boring is the correct goal for a replatform. Expect three to six weeks for a 100-page site with one CMS source and a couple of interactive routes, including redirect verification and a staged cutover.
If you are weighing Gatsby against Astro, Next.js, React Router v7, or staying put, we do a fixed-scope migration assessment: an audit of your plugin and data surface, a URL inventory, a recommendation with the reasoning written down, and an estimate. Get in touch and tell us what the site does today.