Migrate Gatsby to Next.js 16: The Complete App Router Playbook with Effort Estimates (2026)

Real effort estimates and a phase-by-phase playbook for migrating from Gatsby to Next.js 16 App Router: GraphQL to Server Components, plugin mapping, dynamic routes, redirects, and deployment.

Migrate Gatsby to Next.js 16 Guide (2026)

Updated: August 9, 2026

Migrating from Gatsby to Next.js 16 takes anywhere from half a day for a small blog to three weeks for an enterprise site, with the bulk of the work concentrated in three areas: replacing the GraphQL data layer with async Server Components, mapping plugin-based features (image, SEO, sitemap) to Next.js built-ins, and rebuilding gatsby-node.js dynamic page generation as generateStaticParams. I've led a handful of these migrations since Netlify's stewardship of Gatsby cooled, and honestly, the pattern is remarkably consistent. This playbook walks through each phase with real effort estimates so you can plan a realistic timeline.

  • Gatsby's open-source framework is in minimal-maintenance mode since Netlify sunset Gatsby Cloud in 2024; most core contributors have moved to other frameworks.
  • Small blogs (5–20 pages) migrate in 4–8 hours; medium sites (50–100 pages) take 3–5 days; enterprise sites (600+ dynamic pages, i18n) take 2–4 weeks with a small team.
  • The biggest technical shift is dropping Gatsby's centralized GraphQL data layer in favor of Next.js 16 async Server Components that fetch directly from any source.
  • Ten of the most common Gatsby plugins (image, SEO, sitemap, RSS, MDX, sharp, offline) have first-party Next.js equivalents that need zero external dependencies.
  • Use gatsby-plugin-next for incremental page-by-page migration on large sites; do a full cutover for anything under 100 pages.
  • Skip the Pages Router entirely and migrate straight to the App Router with Server Components, since Pages Router is now legacy in Next.js 16.

Is Gatsby still supported in 2026?

Gatsby is technically still an open-source project on npm, but for practical planning purposes I treat it as end-of-life. After Netlify sunset Gatsby Cloud in early 2024, the framework's commit velocity dropped sharply, most named core contributors moved to other projects, and a Netlify product manager confirmed in community threads that the open-source framework would not receive meaningful investment. The GitHub repo still accepts occasional PRs, but there is no roadmap, no release cadence, and no dedicated maintainer team.

The concrete pain points I see in Gatsby projects in 2026:

  • Plugin rot. Roughly a third of the ~2,300 gatsby-plugin-* packages on npm haven't been updated in over 18 months. When a plugin like gatsby-source-shopify breaks because an upstream API version is sunsetted, there is no fix incoming.
  • React 19 lag. Gatsby is stuck several React majors behind, so you can't adopt Server Components, the use() hook, or the React Compiler.
  • Build times. The GraphQL data layer that made Gatsby fast in 2019 now feels sluggish next to Turbopack-powered Next.js 16 dev servers.
  • Hiring signal. New engineers expect Next.js or a similar App-Router framework; Gatsby experience is increasingly a niche skill.

None of this means your Gatsby site is broken today. It just means every quarter you defer migrating, the migration itself gets a little harder as the plugin ecosystem drifts further from working combinations.

How long does a Gatsby to Next.js migration take?

I estimate migrations in days, not hours, because the calendar time always exceeds the coding time. You'll spend a surprising amount of it reconciling design regressions, missing meta tags, and broken redirects that only surface in production. Here's the rubric I use with clients:

Site profilePagesPluginsEffort (small team)Recommended approach
Personal blog5–20 mostly static3–50.5–1 dayFull cutover, one branch
Marketing site20–100 with CMS8–153–5 daysFull cutover with staging
Documentation site100–500 MDX10–201–2 weeksFull cutover, careful redirects
E-commerce / Shopify200–2000 dynamic15–302–3 weeksIncremental with gatsby-plugin-next
Enterprise multilingual500+ with 5+ locales25+3–6 weeksIncremental, phased locale rollout

These numbers assume a team that already knows React and has shipped at least one Next.js App Router project. Add 30–50% for a team learning the App Router paradigm at the same time. The Vercel engineering team documented a three-week migration of a 600+ page multilingual site with a small team and zero downtime, which is a useful high-water mark for what "large" looks like in practice.

Phase 1: Project setup and dependencies

Start by creating a fresh Next.js 16 project rather than mutating the existing Gatsby repo in place. This gives you a clean baseline and lets you copy components across one at a time. If you're worried about losing git history, use a subtree merge later. Don't fight to reuse the old package.json.

# In a new sibling directory
npx create-next-app@latest my-site-next --typescript --app --tailwind --eslint

cd my-site-next
# Copy over the src/components and src/styles directories from Gatsby
cp -r ../my-site-gatsby/src/components ./src/
cp -r ../my-site-gatsby/src/styles ./src/
cp -r ../my-site-gatsby/static/* ./public/

Uninstall every gatsby* package as you go. Keep react and react-dom, since Next.js will pin them to React 19 automatically. The typical package.json diff removes 20–40 dependencies and adds fewer than 5.

// package.json (after cleanup)
{
  "dependencies": {
    "next": "^16.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}

Two directories change name: Gatsby's static/ becomes Next.js's public/ (Next.js serves anything in public/ at the site root), and the build output moves from public/ in Gatsby to .next/ in Next.js. Add .next/ to .gitignore and delete any lingering public/ in the repo root that Gatsby created.

Phase 2: Migrate routing and pages

Both frameworks are file-system routed, so this phase is mostly rename-and-move work. Gatsby's src/pages/about.js becomes Next.js's app/about/page.tsx. The App Router uses folder-per-route with a page.tsx convention rather than file-per-route, which lets you colocate layouts, loading states, and error boundaries with each route.

// Gatsby: src/pages/about.js
import React from 'react'
import Layout from '../components/Layout'

export default function About() {
  return (
    <Layout>
      <h1>About</h1>
    </Layout>
  )
}

// Next.js App Router: app/about/page.tsx
export default function AboutPage() {
  return (
    <>
      <h1>About</h1>
    </>
  )
}

// The Layout wrapper moves to app/layout.tsx and applies to all routes.

Replace every gatsby-link import with next/link. The API is close enough that a find-and-replace covers 90% of cases. The notable differences: next/link uses href instead of to, and it no longer requires a wrapping anchor tag in Next.js 13+. If you have a Pages Router site to reference, my Pages Router to App Router migration playbook covers many of the same conventions in more depth.

// Gatsby
import { Link } from 'gatsby'
<Link to="/blog/hello">Hello</Link>

// Next.js
import Link from 'next/link'
<Link href="/blog/hello">Hello</Link>

Route groups (folders wrapped in parentheses like (marketing)) and parallel routes are App Router features Gatsby has no direct analog for. You'll rediscover the layout patterns you were emulating with wrapper components in Gatsby. If parallel routes are new to you, my walkthrough on Next.js parallel and intercepting routes shows the patterns you'll pick up on this phase.

Phase 3: Replace the GraphQL data layer with Server Components

This is the biggest conceptual shift in the migration. Gatsby's central selling point was its GraphQL data layer: plugins ingested data from many sources (filesystem, CMS, Shopify) into an in-memory Node graph, and pages queried that graph via useStaticQuery or a pageQuery export. Next.js 16 abandons that centralization entirely. Server Components are async functions that fetch data directly from wherever it lives.

// Gatsby: src/pages/blog.js
import { graphql } from 'gatsby'

export const query = graphql`
  query BlogIndex {
    allMarkdownRemark(sort: {frontmatter: {date: DESC}}) {
      nodes {
        frontmatter { title slug date }
        excerpt
      }
    }
  }
`

export default function BlogIndex({ data }) {
  return data.allMarkdownRemark.nodes.map(node => (
    <article key={node.frontmatter.slug}>{node.frontmatter.title}</article>
  ))
}
// Next.js 16: app/blog/page.tsx
import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import matter from 'gray-matter'

async function getPosts() {
  const dir = join(process.cwd(), 'content/posts')
  const files = await readdir(dir)
  const posts = await Promise.all(files.map(async (file) => {
    const raw = await readFile(join(dir, file), 'utf8')
    const { data, excerpt } = matter(raw, { excerpt: true })
    return { ...data, excerpt, slug: file.replace(/\.md$/, '') }
  }))
  return posts.sort((a, b) => b.date.localeCompare(a.date))
}

export default async function BlogIndex() {
  const posts = await getPosts()
  return posts.map(p => (
    <article key={p.slug}>{p.title}</article>
  ))
}

Notice what disappeared: no GraphQL schema, no plugin to configure, no useStaticQuery hook, no fragments. You read from the filesystem with plain Node APIs, or call fetch() against a REST/GraphQL endpoint, or query a database with Drizzle. Next.js caches those calls automatically when the page is statically rendered. My guide on Next.js database integration with Drizzle ORM walks through the database side of this pattern end-to-end.

For CMS-sourced content, the pattern is even simpler because you skip the intermediate GraphQL layer:

// Fetch directly from Contentful, Sanity, Strapi, etc.
export default async function BlogIndex() {
  const res = await fetch('https://cdn.contentful.com/spaces/.../entries', {
    headers: { Authorization: `Bearer ${process.env.CF_TOKEN}` },
    next: { revalidate: 3600 }  // ISR-equivalent: refresh hourly
  })
  const { items } = await res.json()
  return items.map(item => ...)
}

Phase 4: Map Gatsby plugins to Next.js equivalents

The plugin inventory is where migrations get tedious. Most common Gatsby plugins map to Next.js built-ins, not external libraries, which is why the resulting package.json is so much smaller. Here's the mapping I use as a starting point (kept in a Notion doc I've been updating since 2023):

Gatsby pluginNext.js equivalentNotes
gatsby-plugin-image, gatsby-imagenext/imageBuilt-in, no config for local images
gatsby-plugin-sharp, gatsby-transformer-sharpnext/imageHandled automatically by the image optimizer
gatsby-plugin-react-helmetMetadata API (generateMetadata)Type-safe, per-route metadata
gatsby-plugin-sitemapapp/sitemap.tsBuilt-in, returns array of URL objects
gatsby-plugin-manifestapp/manifest.tsSame shape, one file
gatsby-plugin-mdx@next/mdxFirst-party plugin
gatsby-source-filesystemNode fs in Server ComponentsNo plugin needed
gatsby-source-contentful, -shopify, etc.Vendor SDK + fetchDirect API calls in Server Components
gatsby-plugin-offlineSerwist or next-pwaExternal but well-maintained
gatsby-plugin-google-analytics@next/third-partiesFirst-party GA/GTM helpers
gatsby-plugin-robots-txtapp/robots.tsBuilt-in
gatsby-plugin-feed (RSS)Route handler at app/feed.xml/route.tsReturn XML from a route handler

Phase 5: Migrate images and static assets

Gatsby's image story was arguably its best feature. gatsby-plugin-image gave you responsive images, blur-up placeholders, and format negotiation at build time. Next.js 16's next/image component does the same work at request time (or build time for statically imported images) with less configuration and no GraphQL query wrapping.

// Gatsby
import { GatsbyImage, getImage } from "gatsby-plugin-image"
import { graphql } from "gatsby"

export default function Hero({ data }) {
  const image = getImage(data.file)
  return <GatsbyImage image={image} alt="Hero" />
}
export const query = graphql`
  query { file(relativePath: {eq: "hero.jpg"}) { childImageSharp { gatsbyImageData(width: 1200) } } }
`

// Next.js 16
import Image from 'next/image'
import hero from '@/public/hero.jpg'  // static import gets width/height + blur

export default function Hero() {
  return <Image src={hero} alt="Hero" placeholder="blur" priority />
}

For remote images (CMS-hosted), configure remotePatterns in next.config.js to allow the CDN host. My guide on Next.js image optimization with AVIF and remote patterns covers the full config surface, including AVIF format negotiation and Core Web Vitals implications.

Phase 6: SEO, Head, and metadata

Every Gatsby project has a Seo.js or Head.js component that composes react-helmet tags. In Next.js 16, replace that entire pattern with the Metadata API, using either static metadata exports or the dynamic generateMetadata function per route.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage]
    }
  }
}

Sitemap, robots.txt, and manifest all follow the same convention: a file at app/sitemap.ts, app/robots.ts, or app/manifest.ts that default-exports a function returning the appropriate data structure. No plugin, no config file, no rebuild step. See my full walkthrough on the Next.js SEO Metadata API and dynamic OG images for the JSON-LD and Open Graph pieces.

Also import your redirects file at this point. If you were using Netlify's _redirects file, translate the entries into the redirects() function in next.config.js. Getting redirects wrong is the single biggest source of post-migration organic traffic loss, so do this before the DNS cutover, not after. (I've watched a client lose 40% of their organic traffic for two weeks because a trailing-slash rule slipped through. It stings.)

Phase 7: Rebuild gatsby-node.js dynamic pages with generateStaticParams

Gatsby's gatsby-node.js exported a createPages function that iterated over your data and called createPage for each dynamic route. Next.js 16 replaces this with generateStaticParams, an async function inside each dynamic route file that returns the set of params to prebuild.

// Gatsby: gatsby-node.js
exports.createPages = async ({ actions, graphql }) => {
  const { data } = await graphql(`{ allMarkdownRemark { nodes { fields { slug } } } }`)
  data.allMarkdownRemark.nodes.forEach(node => {
    actions.createPage({
      path: `/blog/${node.fields.slug}`,
      component: require.resolve('./src/templates/blog-post.js'),
      context: { slug: node.fields.slug }
    })
  })
}

// Next.js 16: app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map(p => ({ slug: p.slug }))
}

export default async function BlogPost(
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const post = await getPost(slug)
  return <article><h1>{post.title}</h1></article>
}

The generateStaticParams function replaces createPages, and combining it with revalidate gives you Gatsby-parity Incremental Static Regeneration without any additional plugins. For more on the static generation model, see my generateStaticParams and ISR guide.

Incremental migration for large sites

For sites over 100 pages or with active editorial teams that can't tolerate a freeze, use the gatsby-plugin-next plugin to migrate page-by-page. The plugin lets you place Next.js-style pages in a src/next-pages/ directory that Gatsby serves alongside its own routes. You then move one route at a time, ship it, verify, and repeat.

The trade-off is that you're maintaining two frameworks in one repo for weeks. Dependency versions, build caches, and CI configs get gnarly. I only recommend this approach when a big-bang cutover would block the content team from shipping for more than a week. For everything else, the productivity hit from freezing content is smaller than the operational overhead of running two frameworks simultaneously.

A useful hybrid pattern I've used on medium sites: freeze content for a week, run the cutover, and use redirects to handle any URL structure changes. The one-week freeze is cheaper than the alternative.

Deployment: from Netlify to Vercel (or stay on Netlify)

Next.js runs everywhere: Vercel, Netlify, Cloudflare Workers, AWS via SST or OpenNext, and self-hosted via Docker. If you're already on Netlify with a Gatsby site, staying on Netlify is a valid choice. Netlify supports Next.js 16 App Router with ISR and Server Components via their Next.js Runtime. Migration in place means no DNS changes, no environment variable re-entry, and no billing changes.

That said, most teams I've worked with move to Vercel to get first-class Next.js features (edge middleware, Vercel KV, Vercel Blob, Image Optimizer) without adapter friction. If you're deploying elsewhere, my guide on self-hosting Next.js with Docker covers the standalone output mode for containerized deploys.

Whichever platform you choose, run a staging deploy against production data before the cutover. Compare rendered HTML for the top 20 pages by traffic, check Cache-Control headers, verify structured data with Google's Rich Results test, and crawl the site with Screaming Frog or a similar tool to catch broken internal links.

Frequently Asked Questions

Should I migrate to the Pages Router or App Router?

Go straight to the App Router. In Next.js 16 the App Router is the default and only actively developed router; Pages Router receives security fixes but no new features. Migrating twice (Gatsby to Pages Router to App Router) is more work than migrating once. The App Router's Server Components model also maps more naturally to Gatsby's build-time data fetching than the Pages Router's getStaticProps does.

Can I keep my Markdown files and frontmatter?

Yes. Move your content/ or posts/ directory into the Next.js project unchanged and read the files from a Server Component with fs and gray-matter. For rich Markdown with React components, use @next/mdx. It supports the same frontmatter and JSX-in-Markdown patterns Gatsby's MDX plugin provided.

What happens to my SEO rankings during migration?

Rankings hold if URLs stay identical and metadata is preserved. The most common cause of post-migration ranking drops is silent URL changes (trailing slashes, case sensitivity, category renames) that aren't caught by redirects. Before cutover, export a full URL list from Google Search Console and diff it against your new Next.js sitemap. Every mismatched URL needs a 301 redirect in next.config.js.

Do I still need GraphQL after migrating?

Only if your data source is GraphQL (a Contentful GraphQL endpoint, a Shopify Storefront API, a Hasura backend). In that case you call the GraphQL endpoint directly from a Server Component with fetch. You don't need Gatsby's plugin layer or Apollo Client. For non-GraphQL sources (filesystem, REST APIs, databases), GraphQL disappears entirely from your stack.

How do I handle client-side interactivity that used gatsby-browser.js?

gatsby-browser.js's onClientEntry and wrapPageElement hooks translate to two Next.js patterns: put global providers (theme, analytics, auth) in the root app/layout.tsx wrapped in a Client Component, and put per-page client logic in a use client component imported by the Server Component page. Third-party scripts move to next/script or @next/third-parties.

Jasmine Patel
About the Author Jasmine Patel

Web framework specialist comparing Next.js to everything else so you don't have to. Migrates teams off legacy stacks for fun.