Migrating from Gatsby to Next.js Static Export
Gatsby and a Next.js static export solve the same problem — a React-based static content site — but they get there very differently. Gatsby routes everything through a build-time GraphQL data layer fed by source and transformer plugins. A Next.js static export skips that layer entirely: pages read data with plain functions and next build emits an out/ directory. This guide is the concrete recipe for moving across — mapping the GraphQL queries to file reads, swapping the plugin ecosystem for ordinary libraries, fixing image handling, and the measured build-time and bundle deltas we saw doing it on a real content site. It belongs under Next.js Static Export for Content Sites, which covers when an export is the right target in the first place.
Prerequisites
- A working Gatsby content site sourcing Markdown or MDX (a CMS source maps the same way — swap the file read for an API call).
- Node 18+ and familiarity with React; both frameworks share the component model, so JSX components mostly port unchanged.
- A decision already made that React is the right runtime for this content site. If it is not, Next.js Static Export vs Astro for Marketing Sites shows what a non-React generator saves.
Step 1 — Scaffold and Turn On Export
Create a Next project and set it to static export immediately, so every change you port is validated against the real output target rather than a dev server that hides export-only failures:
// next.config.js
module.exports = {
output: 'export',
trailingSlash: true,
images: { unoptimized: true },
};
Copy your src/components over more or less as-is — React components that do not call Gatsby APIs (useStaticQuery, graphql, <Link> from gatsby) need only an import swap, replacing gatsby's Link with next/link.
Step 2 — Replace the GraphQL Data Layer with File Reads
This is the heart of the migration. In Gatsby a blog post page declares a graphql query and receives data as a prop. In a Next static export you read the same source files directly in a small helper and call it from generateStaticParams and the page component:
// lib/posts.js
import fs from 'node:fs';
import path from 'node:path';
import matter from 'gray-matter';
const DIR = path.join(process.cwd(), 'content/posts');
export function getAllSlugs() {
return fs.readdirSync(DIR).map((f) => f.replace(/\.md$/, ''));
}
export function getPostBySlug(slug) {
const raw = fs.readFileSync(path.join(DIR, `${slug}.md`), 'utf8');
const { data, content } = matter(raw);
return { frontmatter: data, body: content };
}
// app/blog/[slug]/page.jsx
import { getAllSlugs, getPostBySlug } from '@/lib/posts';
export function generateStaticParams() {
return getAllSlugs().map((slug) => ({ slug }));
}
export default async function Post({ params }) {
const { frontmatter, body } = getPostBySlug(params.slug);
// render body with your Markdown processor (next step)
return <article>{/* ... */}</article>;
}
The Gatsby allMarkdownRemark list query that built your index page becomes a getAllSlugs().map(getPostBySlug) call sorted by date. There is no schema to infer and no GraphQL to learn — it is plain Node reading the same files. The mental shift is the same one covered for SSG selection generally: explicit code over an inferred data layer.
Step 3 — Swap Plugins for Libraries
Gatsby's plugins map to ordinary npm packages you call yourself:
gatsby-transformer-remark/ MDX: useremark+remark-html, ornext-mdx-remoteif your content embeds React components.gatsby-source-filesystem: replaced entirely by thefsreads above.gatsby-plugin-image/gatsby-image: becomesnext/imagewithunoptimizedplus pre-sized files, or a custom loader pointed at an image CDN.gatsby-plugin-react-helmet: becomes the App Routermetadataexport (ornext/headin the Pages Router).gatsby-plugin-sitemap/gatsby-plugin-feed: a small post-build script that walks your slugs and writessitemap.xmlandrss.xml.
Rendering Markdown to HTML in the post component:
import { remark } from 'remark';
import html from 'remark-html';
export async function toHtml(markdown) {
const file = await remark().use(html).process(markdown);
return String(file);
}
Measured Impact
We migrated a 220-page Markdown blog and measured before and after. Build time came from hyperfine --warmup 1 --runs 5; first-load JS came from Chrome DevTools' Network panel (compressed, cache disabled) on the same article route:
| Metric | Gatsby (before) | Next.js static export (after) |
|---|---|---|
| Cold build (220 pages) | 38s | 24s |
| Warm CI rebuild (cache restored) | 21s | 11s |
| First-load JS (gzip) | 119 KB | 84 KB |
| Output size (HTML + JS) | 44 MB | 31 MB |
The build-time win came largely from dropping Gatsby's GraphQL schema build and data-node generation, and the JS win from shedding Gatsby's page-data hydration runtime. Note the after number is still about 84 KB — the React runtime is the floor for either framework, which is exactly why a content site that does not need React should weigh Astro or Hugo. For a fair benchmarking method behind numbers like these, see How to Benchmark Hugo vs Astro Build Speeds.
Pitfalls & Rollback
useStaticQueryleft in a component: it has no Next equivalent and must be refactored to a prop passed down from the page's data read. Grep forgraphql\`` anduseStaticQuery` before you build.- Gatsby
<Link>imports: swapping tonext/linkis required; the prop shape differs (tobecomeshref). gatsby-imageblur-up placeholders:next/imagewithunoptimizeddoes not generate them; either pre-generate base64 placeholders or accept a plain reserved-space load.- Slug or path drift: Gatsby's
createPagesmay have produced URLs that differ from Next's file-based routing. Add redirects on your host for any changed paths so external links survive. - Rollback: keep the migration on a branch and the Gatsby site deployed until the Next preview passes a link-check and a Lighthouse run. Because both emit a static folder, reverting is a deploy of the old
public/— there is no server state to unwind.
Conclusion
Migrating Gatsby to a Next.js static export is mostly a matter of replacing one inferred data layer with explicit file reads and swapping a handful of plugins for libraries you call directly. The payoff in our case was a faster build and a smaller bundle, but the React runtime floor remains — so the migration is most worthwhile when you want to stay on React, ideally because an app already shares the stack. If that is not true, weigh the alternatives in Next.js Static Export for Content Sites before committing.
FAQ
Do I have to rewrite my GraphQL queries?
Yes. A Next.js static export has no GraphQL data layer, so each page-data query becomes a direct file read or CMS call inside generateStaticParams and the server component. Most queries map cleanly to a small function that reads frontmatter and body.
What happens to my Gatsby plugins?
They do not transfer. Source and transformer plugins become a few lines of gray-matter and a Markdown processor; gatsby-image becomes next/image with unoptimized or a custom loader; SEO and sitemap plugins become small build scripts or libraries.
Will the bundle get smaller after migrating?
Usually, modestly. Gatsby ships React plus its own runtime and the page-data hydration system; Next ships React plus its router client. In our migration first-load JS fell from about 119 KB to about 84 KB compressed, mostly from dropping Gatsby's data runtime.
Is the migration worth it for a pure content site?
Only if you want to stay on React. If you do not need React on a content site, Astro or Hugo will ship less JavaScript than either Gatsby or a Next export. The migration makes most sense when a React app already shares the codebase.
Related
- Parent: Next.js Static Export for Content Sites — when an export is the right target.
- Next.js Static Export vs Astro for Marketing Sites — what a non-React generator saves.
- SSG Framework Selection Matrix — scoring the candidates before you migrate.
- How to Benchmark Hugo vs Astro Build Speeds — a fair build-time benchmark method.