+1 (415) 779-8456

Migrating a Gatsby v4/v5 Site to Astro 5, Step by Step

Gatsby is in maintenance mode, and for content-led sites Astro 5 is the migration target we recommend most often: its content collections map almost directly onto Gatsby's markdown sourcing, it ships no JavaScript by default, and your existing React components can stay as islands. This tutorial walks through a typical Gatsby v4/v5 blog or marketing site and moves it to Astro 5 one layer at a time. The commands and APIs are current as of Astro 5 and Gatsby 5.

1. Audit the plugins before writing code

Start by listing every plugin in gatsby-config.js and deciding what happens to it. Most fall into four buckets:

Gatsby pluginWhat it didIn Astro 5
gatsby-source-filesystem + gatsby-transformer-remark / gatsby-plugin-mdxRead markdown/MDX from diskContent collections with the glob loader, @astrojs/mdx
gatsby-plugin-image + gatsby-plugin-sharp + gatsby-transformer-sharpResponsive imagesastro:assets (built in)
gatsby-plugin-react-helmet / gatsby-plugin-seo<head> managementPlain <head> in layouts
gatsby-plugin-sitemap, gatsby-plugin-manifest, gatsby-plugin-google-gtagSitemap, PWA manifest, analytics@astrojs/sitemap, a static manifest.webmanifest, a script tag or @astrojs/partytown
gatsby-plugin-gatsby-cloudGatsby Cloud headers/redirectsDelete; the service no longer exists

Anything in the last bucket, and anything you cannot explain, is a candidate for deletion rather than replacement.

2. Scaffold the Astro project

npm create astro@latest my-site -- --template minimal --typescript strict
cd my-site
npx astro add mdx sitemap react

astro add react is only needed if you intend to keep React components as islands. If your Gatsby components are purely presentational, rewriting them as .astro components usually removes more code than it adds.

3. Move content into a collection

Gatsby sourced posts from content/posts/*.md and exposed them through GraphQL. In Astro 5 the same directory becomes a content collection defined in src/content.config.ts:

// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const posts = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './content/posts' }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      date: z.coerce.date(),
      description: z.string().max(160),
      cover: image().optional(),
      draft: z.boolean().default(false),
    }),
});

export const collections = { posts };

The schema is where Gatsby's implicit frontmatter contract becomes explicit. Run npx astro check after defining it; every post with a missing or malformed field is reported at build time, which is a better failure mode than a GraphQL query silently returning null.

If a Gatsby post used a slug field from gatsby-node.js (createNodeField), you can compute the same value in the loader with generateId, or simply keep the file path as the id, which is what the glob loader does by default.

4. Replace page queries with getCollection

A Gatsby blog index typically looked like this:

// src/pages/blog.js (Gatsby)
export const query = graphql`
  query {
    allMarkdownRemark(sort: { frontmatter: { date: DESC } }) {
      nodes { id excerpt fields { slug } frontmatter { title date } }
    }
  }
`;

In Astro the query is a function call in the page's frontmatter:

---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

const posts = (await getCollection('posts', ({ data }) => !data.draft))
  .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
---
<Layout title="Blog">
  <ul>
    {posts.map((post) => (
      <li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
    ))}
  </ul>
</Layout>

Gatsby's createPages in gatsby-node.js becomes getStaticPaths in a dynamic route:

---
// src/pages/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('posts');
  return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---
<Layout title={post.data.title} description={post.data.description}>
  <article><Content /></article>
</Layout>

Note render() is imported from astro:content in Astro 5; the entry.render() method from Astro 4 was removed with the new content layer.

5. Images: gatsby-plugin-image to astro:assets

A Gatsby post image went through childImageSharp in GraphQL and rendered with GatsbyImage:

<GatsbyImage image={getImage(post.frontmatter.cover)} alt={post.frontmatter.title} />

In Astro, because the schema above declared cover: image(), the entry already carries image metadata:

---
import { Image } from 'astro:assets';
---
{post.data.cover && (
  <Image src={post.data.cover} alt={post.data.title} widths={[480, 960, 1440]} sizes="(max-width: 960px) 100vw, 960px" />
)}

Images referenced inside markdown bodies with relative paths are optimised automatically. The one thing to check is images in static/ on the Gatsby side: move them to public/ in Astro and they are served as-is, or move them under src/ to get optimisation.

6. Port MDX components

Gatsby's MDXProvider mapping becomes the components prop on <Content />:

---
import Callout from '../../components/Callout.astro';
const { Content } = await render(post);
---
<Content components={{ Callout }} />

React components inside MDX still work after astro add react, but each one that needs interactivity must carry a client directive (client:load, client:visible) or it renders as static HTML. That is the single largest behavioural difference from Gatsby, where every component hydrated.

7. Redirects and sitemap parity

Pull the list of indexed URLs from the old sitemap.xml and Search Console before you change anything. Any path that changes goes in astro.config.mjs:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://www.example.com',
  trailingSlash: 'always',
  redirects: {
    '/blog/2021/old-post-slug': '/blog/old-post-slug/',
  },
  integrations: [sitemap()],
});

Two details bite almost every migration. Gatsby generated trailing-slash URLs by default, so set trailingSlash to match the old site rather than letting both variants exist. And Gatsby's sitemap plugin emitted /index for some sites; make sure the new sitemap lists / only.

Diff the two sitemaps before cutover:

curl -s https://www.example.com/sitemap.xml | grep -o '<loc>[^<]*' | sed 's/<loc>//' | sort > old.txt
curl -s https://staging.example.com/sitemap-0.xml | grep -o '<loc>[^<]*' | sed 's/<loc>//' | sed 's#staging\.##' | sort > new.txt
comm -23 old.txt new.txt   # URLs that exist on the old site but not the new

Every line of output needs either a page or a redirect.

8. Measure before and after

Capture Lighthouse scores and build times on the Gatsby site before you start, on the same pages you will test afterwards:

npx lighthouse https://www.example.com/blog/some-post/ --output=json --output-path=./before.json --only-categories=performance,seo
time gatsby build

Repeat on the Astro build. The typical pattern for a content site is a large drop in shipped JavaScript (Astro only hydrates islands) and a build that is measured in seconds rather than minutes, but your numbers are the ones that matter, and they are what justify the cutover to whoever signs off on it.

What we have not covered

Sites that rely on Gatsby's DSG or SSR routes, headless CMS sources with preview workflows, or heavy client-side routing need a design conversation first; Astro can handle all three, but the mapping is not one-to-one. That is the kind of thing our migration assessment exists for.