Skip to content
techpotions
migration · Next.js · Gatsby · MDX · SEO · App Router · redirects · image optimization · webperfOctober 20, 20268 min read

Migrate From Gatsby to Next.js Without Tanking Rankings

Map Gatsby’s GraphQL layer, image pipeline, createPages hooks, and MDX onto the App Router—then lock in SEO with redirects, trailing slashes, and canonical tags that actually hold rankings.

Cover illustration for “Migrate From Gatsby to Next.js Without Tanking Rankings”

When you decide to migrate from Gatsby to Next.js, the real threat isn't the JavaScript — it’s that your URLs change shape and search consoles light up with 404s. A studio experiment called Minuit taught us something beyond editorial pacing: a Next.js rewrite that shifts the routing model, the image processor, and the content pipeline demands a parallel redirect strategy from day one. This guide maps Gatsby’s GraphQL data layer, gatsby-plugin-image, createPages, and MDX onto the Next.js App Router step by step, then walks through the unglamorous parts — trailing slashes, canonical tags, and redirect hygiene — that decide whether your rankings hold.

Replace Gatsby’s GraphQL Data Layer With Async Server Components

The takeaway: Gatsby’s static GraphQL queries move into async React Server Components. You lose the unified schema, but you gain smaller builds and native TypeScript contracts without plugins.

In a Gatsby project, a page (or any component hooked to graphql) might query frontmatter and Markdown like this:

Text
export const query = graphql`
  query BlogPostBySlug($slug: String!) {
    mdx(fields: { slug: { eq: $slug } }) {
      frontmatter {
        title
        date
        excerpt
      }
      body
    }
  }
`;

The Gatsby build compiles this into a static JSON payload that a page-template React component receives through data prop. When you migrate from Gatsby to Next.js, that compile-time magic disappears, and you get something simpler but more explicit.

Inside the App Router, you ditch the GraphQL query and read the content directly. With MDX stored as files in a known directory (content/posts/ is typical), an async Server Component pulls the source and compiles it:

TSX
import { compileMDX } from 'next-mdx-remote/rsc';
import fs from 'fs/promises';
import path from 'path';

export default async function PostPage({ params }) {
  const source = await fs.readFile(
    path.join(process.cwd(), 'content/posts', `${params.slug}.mdx`),
    'utf8'
  );

  const { content, frontmatter } = await compileMDX({
    source,
    options: { parseFrontmatter: true },
  });

  return (
    <article>
      <h1>{frontmatter.title}</h1>
      {content}
    </article>
  );
}

If you’ve got hundreds of Gatsby createResolvers creating computed fields (fields.slug, reading time from raw body), you offload those into a utility function that runs before the MDX hits the component. No GraphQL node model, no allMarkdownRemark — just raw files and a function.

For structured data that did live in an external API and flowed through Gatsby’s sourceNodes / createSchemaCustomization, swap in fetch() inside a Server Component (or Route Handler). The data arrives on the server, and the browser never sees a waterfall. Teams we work with on migrations treat this as the moment they can finally drop the Gatsby source plugin dependency tree.

From gatsby-plugin-image to next/image Without Losing Responsiveness

The takeaway: Next.js Image component with placeholder="blur" and a statically imported blur-hash gives you the same progressive loading Gatsby pioneered, now with zero GraphQL fragments.

Gatsby’s image processing ran inside gatsby-plugin-sharp and generated fluid/fixed arrays with base64 blur-up placeholders. A typical fragment:

Text
image {
  childImageSharp {
    gatsbyImageData(width: 800, placeholder: BLURRED, formats: [AUTO, WEBP])
  }
}

When you migrate from Gatsby to Next.js, this chain disappears. Next.js handles image optimization through next/image at request time or at build time with static imports. The trick to keeping the same perceived performance (no layout shift, a colored placeholder) is a build-time blurhash:

TSX
import Image from 'next/image';
import heroImage from '@/images/hero.jpg';
import { getPlaiceholder } from 'plaiceholder';

// Run getPlaiceholder inside a build step or a cached utility.
// It produces a base64 string you embed directly.
export default function Hero() {
  return (
    <Image
      src={heroImage}
      alt="editorial spread"
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGB…"
      sizes="(max-width: 768px) 100vw, 50vw"
      priority
    />
  );
}

No GraphQL fragment, no gatsby-plugin-image wrapper. If you need dynamic images (CMS-hosted), a Route Handler that pipes the remote URL through Next.js’s image optimization API replaces gatsby-source-filesystem and gatsby-transformer-sharp in one stroke.

Recreate gatsby-node Routing Hooks in the App Router

The takeaway: createPages becomes generateStaticParams (or dynamicParams); createRedirects becomes a redirects object inside next.config.js or a middleware.ts edge function. There is no onCreateNode equivalent — you preprocess content instead.

A Gatsby gatsby-node.js that builds programmatic pages from a CMS looks like this:

JavaScript
exports.createPages = async ({ graphql, actions }) => {
  const result = await graphql(`query { allArticle { nodes { slug } } }`);
  result.data.allArticle.nodes.forEach(({ slug }) => {
    actions.createPage({
      path: `/articles/${slug}`,
      component: path.resolve('./src/templates/article.js'),
      context: { slug },
    });
  });
};

In the App Router, you scaffold a folder structure that is the route map (app/articles/[slug]/page.tsx) and export generateStaticParams:

TypeScript
export async function generateStaticParams() {
  const articles = await fetchArticles(); // from your CMS or filesystem
  return articles.map((a) => ({ slug: a.slug }));
}

For dynamic routes where you rely on ISR and fallback pages, dynamicParams = true (the App Router default) replaces Gatsby’s “deferred static generation” with incremental on-demand regeneration.

Redirect logic that used createRedirect or createRedirects moves to next.config.js:

JavaScript
// next.config.js
module.exports = {
  async redirects() {
    return [
      {
        source: '/old-blog/:slug',
        destination: '/articles/:slug',
        permanent: true,
      },
      {
        source: '/rss.xml',
        destination: '/feed.xml',
        permanent: true,
      },
    ];
  },
};

If your Gatsby site leaned on onCreateNode to mutate Markdown ASTs (e.g., extracting headings, injecting link icons), extract that logic into a preprocessing script that runs before the build (a simple Node script, or a custom remark/rehype plugin wired into the MDX pipeline). Our migration solutions embed this into a content package shared across frameworks so the transformation runs once, not per-page.

MDX Handling That Doesn’t Break Titles, Code Blocks, or Embeds

The takeaway: Move your MDX plugins into a unified pipeline (next-mdx-remote/rsc or @next/mdx) and verify that frontmatter keys match the GraphQL fields your old templates consumed. The compiler changes; the serialized output should not.

Gatsby’s gatsby-plugin-mdx plugged remark plugins through a gatsby-config array:

JavaScript
{
  resolve: 'gatsby-plugin-mdx',
  options: {
    remarkPlugins: [remarkGfm, remarkMath],
    rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
  },
}

In Next.js, next-mdx-remote/rsc accepts the same plugin signatures:

TSX
const { content } = await compileMDX({
  source,
  options: {
    parseFrontmatter: true,
    mdxOptions: {
      remarkPlugins: [remarkGfm, remarkMath],
      rehypePlugins: [
        rehypeSlug,
        [rehypeAutolinkHeadings, { behavior: 'wrap' }],
      ],
    },
  },
});

The pedantic part is frontmatter shape. Gatsby often injected fields.slug and fields.date through onCreateNode, meaning templates referenced post.fields.slug. When you migrate, those fields disappear. Audit every template for fields.* references and replace them with the raw frontmatter key (post.slug). This is the single most common source of broken MDX after a Gatsby-to-Next.js migration.

If you prefer to colocate compilation without the runtime overhead of next-mdx-remote/rsc, @next/mdx is the alternative that compiles MDX at the page-module level. The trade-off: you lose frontmatter parsing in the same pass, so you’ll need gray-matter to extract it separately.

Trailing Slashes, Canonical Tags, and Redirect Hygiene

The takeaway: URL normalization is the difference between “we moved to Next.js” and “our organic traffic fell 30 %.” Align trailing-slash policy, canonical tags, and redirect chains before launch.

Gatsby’s default behavior (gatsby-plugin-canonical-urls plus a trailing-slash config) often silently 301s or serves both /about and /about/. Next.js’s App Router has a strict preference controlled by trailingSlash:

JavaScript
// next.config.js
module.exports = {
  trailingSlash: false, // or true, but it must match your canonical policy
};

If your Gatsby site historically served pages at /blog/my-post/ (trailing slash) and you deploy the Next.js version at /blog/my-post (no trailing slash) without redirects, search engines see two new URLs and zero signals carrying over. The fix is a blanket redirect in next.config.js that enforces exactly one shape:

JavaScript
async redirects() {
  return [
    {
      source: '/:path*/',
      destination: '/:path*',
      permanent: true,
    },
  ];
}

Combine that with a canonical tag that always points to the normalized path. A <head> export inside layout.tsx that reads headers() from next/headers and constructs the absolute URL covers it:

TSX
export async function generateMetadata({ params }) {
  const slug = params.slug;
  return {
    alternates: {
      canonical: `https://yoursite.com/articles/${slug}`,
    },
  };
}

When you migrate from Gatsby to Next.js, also audit every internal hard-coded <a href> — they often carry legacy trailing slashes or .html extensions that Gatsby’s Link component silently resolved. The App Router’s <Link> does no such normalization; it renders the href you give it. A broken internal link that 301s is a wasted crawl budget signal.

The Minuit editorial experiment was a re-platform that had to preserve zero URLs because it was a new project on a fresh domain, but it underscored a principle: the scroll choreography meant nothing if the page wouldn’t resolve cleanly. The same hygiene applies when you migrate a Gatsby site with hundreds of indexed pages.

FAQ

Will my page-level SEO metadata survive the migration?

Yes, if you map every Gatsby Helmet / gatsby-plugin-react-helmet meta tag into the App Router’s generateMetadata (or an exported metadata object). Inspect your Gatsby build output to catalog every <title>, <meta name="description">, <meta property="og:*">, and <link rel="canonical"> tag, then replicate them inside the corresponding Next.js route segment.

We have thousands of Gatsby nodes built from a headless CMS. How do we handle incremental updates without createPages rebuilding the whole site?

Use Next.js On-Demand ISR with revalidatePath or revalidateTag. When your CMS webhook fires, call an API route that revalidates only the changed path. This replaces Gatsby Cloud’s incremental builds and does not require a GraphQL schema.

Do we lose Gatsby’s offline support and service worker?

Gatsby ships gatsby-plugin-offline with Workbox. Next.js does not bundle a service worker by default. If your PWA metrics (repeat visits, offline reads) matter, add next-pwa or a custom Workbox script inside public/sw.js. This is additive — framework migration does not strip it unless you omit it.

Written by
techpotions
All entries
Multi-Tenant vs Single-Tenant: The Cost Decision
The weekly

One email a week, from the workshop.

What we published, what we shipped, and the free packs as they land. No drip sequence, no webinar, unsubscribe in one click.