+1 (415) 779-8456

Gatsby to Next.js App Router: Mapping the Data Layer

When a Gatsby site is really an application — authenticated areas, a shared component library with a product, personalised content — Next.js with the App Router is usually the better migration target than Astro. The components mostly move as-is. What changes is the data layer: Gatsby put a GraphQL schema between your content and your pages, and Next.js does not. This tutorial maps each piece of Gatsby's data layer to its App Router equivalent, with code for each.

The mental model shift

Gatsby's pipeline was: source plugins load data into nodes, Gatsby builds a GraphQL schema from those nodes, pages query the schema at build time, and createPages in gatsby-node.js turns data into routes.

In the App Router there is no intermediate schema. Server components fetch data directly, generateStaticParams turns data into routes, and the output is static, dynamic, or a mix depending on what you fetch and how. Most of a Gatsby migration to Next.js is deleting the GraphQL layer and calling the underlying source directly.

Step 1: createPages becomes generateStaticParams

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: `/blog/${node.fields.slug}`,
      component: require.resolve('./src/templates/post.js'),
      context: { slug: node.fields.slug },
    });
  });
};

Next.js App Router:

// app/blog/[slug]/page.tsx
import { getAllPosts, getPost } from '@/lib/posts';

export const dynamicParams = false; // 404 for slugs not returned below

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <article dangerouslySetInnerHTML={{ __html: post.html }} />;
}

Two things to notice. params is a Promise in current Next.js and must be awaited. And dynamicParams = false reproduces Gatsby's behaviour where only pages created at build time exist; leave it at the default true if you want unknown slugs rendered on demand.

Step 2: replace the GraphQL layer with a source module

Gatsby's gatsby-source-filesystem + gatsby-transformer-remark pair becomes a small module you own:

// lib/posts.ts
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import remarkHtml from 'remark-html';

const POSTS_DIR = path.join(process.cwd(), 'content', 'posts');

export async function getAllPosts() {
  const files = (await readdir(POSTS_DIR)).filter((f) => f.endsWith('.md'));
  const posts = await Promise.all(
    files.map(async (file) => {
      const raw = await readFile(path.join(POSTS_DIR, file), 'utf8');
      const { data } = matter(raw);
      return { slug: file.replace(/\.md$/, ''), title: data.title as string, date: new Date(data.date) };
    }),
  );
  return posts.sort((a, b) => b.date.valueOf() - a.date.valueOf());
}

export async function getPost(slug: string) {
  const raw = await readFile(path.join(POSTS_DIR, `${slug}.md`), 'utf8');
  const { data, content } = matter(raw);
  const html = String(await remark().use(remarkGfm).use(remarkHtml).process(content));
  return { slug, title: data.title as string, description: data.description as string, html };
}

Install the dependencies with npm install gray-matter remark remark-gfm remark-html. For MDX, use @next/mdx or next-mdx-remote instead of remark-html; the rest of the module stays the same.

If your Gatsby site sourced from a headless CMS (gatsby-source-contentful, gatsby-source-sanity, gatsby-source-wordpress), the source module calls the CMS SDK directly. This is usually less code than the Gatsby plugin configuration it replaces, because you only fetch the fields the page uses.

Step 3: useStaticQuery becomes a plain import or a server fetch

Gatsby components pulled site metadata with a hook:

const { site } = useStaticQuery(graphql`
  { site { siteMetadata { title } } }
`);

In Next.js, site metadata is a module:

// lib/site.ts
export const site = { title: 'Example', url: 'https://www.example.com' } as const;

Anything that needs real data at build time is an async server component. Components that must stay client-side ('use client') receive data as props from a server component parent; they do not fetch it themselves.

Step 4: gatsby-plugin-image becomes next/image

Gatsby:

import { GatsbyImage, getImage } from 'gatsby-plugin-image';
<GatsbyImage image={getImage(data.cover)} alt="" />

Next.js:

import Image from 'next/image';
import cover from '@/content/images/cover.jpg';

<Image src={cover} alt="" sizes="(max-width: 960px) 100vw, 960px" placeholder="blur" />

Statically imported images get width, height, and a blur placeholder automatically, which is the closest equivalent to gatsbyImageData with placeholder: "blurred". For remote CMS images, add the host to images.remotePatterns in next.config.ts and pass explicit width and height.

One caveat for fully static exports (output: 'export'): the default image optimiser needs a server. Either host on a platform that provides one, or set images.unoptimized: true and do the resizing in your CMS or build step.

Step 5: the SEO component becomes the Metadata API

Gatsby sites almost always have a <Seo /> component wrapping react-helmet. Delete it. Static metadata is an export:

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL('https://www.example.com'),
  title: { default: 'Example', template: '%s | Example' },
  description: 'Site description under 160 characters.',
};

Per-page metadata that depends on data uses generateMetadata:

// 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.description,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: { title: post.title, description: post.description, type: 'article' },
  };
}

metadataBase is what turns the relative canonical and Open Graph URLs into absolute ones; forgetting it is the most common cause of broken social previews after a migration.

Step 6: redirects, sitemap, and deployment

Gatsby redirects lived in createRedirect calls or a host-specific _redirects file. In Next.js they are configuration:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  trailingSlash: true, // match Gatsby's default URL shape
  async redirects() {
    return [{ source: '/blog/2021/:slug', destination: '/blog/:slug', permanent: true }];
  },
};

export default nextConfig;

The sitemap is a route file:

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();
  return [
    { url: 'https://www.example.com/', lastModified: new Date() },
    ...posts.map((p) => ({ url: `https://www.example.com/blog/${p.slug}/`, lastModified: p.date })),
  ];
}

Note that redirects() in next.config.ts is not applied by a static export; if you deploy with output: 'export' to S3 + CloudFront or GitHub Pages, the redirects must move to the host (a CloudFront Function, for example). Vercel, Netlify, and Cloudflare all honour them directly.

Checklist before cutover

  • Every URL in the old sitemap either renders or 301s on staging.
  • generateMetadata output diffed against the Gatsby <head> for the top twenty pages by traffic.
  • Lighthouse on the same three pages before and after.
  • next build output reviewed: pages you expected to be static are marked static, not dynamic.

If the site also has authenticated or personalised routes, the same data-layer mapping applies; the difference is that those routes stay dynamic instead of static. Our Gatsby migration service starts with exactly this mapping exercise, written up per page, before any code is moved.