Replacing Gatsby GraphQL Queries During a Migration

Gatsby's GraphQL data layer is the part of a Gatsby site that does not move. Templates, components and Markdown translate to any React-friendly generator; the queries that feed them do not, because no other generator has the same data layer. A Gatsby site with years of history can hold dozens of page queries, static queries in components, and a gatsby-node.js whose createPages stitches it all together. Each has to become something else: a content collection query, a data file, a function.

This guide replaces 47 GraphQL queries during a migration from Gatsby 4 to Astro 5 for a 1,900-page company blog and docs site. It shows how to inventory the queries, map each kind to its replacement, handle images, and verify the output page by page. It belongs to Migrating Between Static Site Generators; the whole migration is covered in Migrating from Gatsby to Astro.

Prerequisites

  • The Gatsby site building, with its output kept for comparison.
  • The destination project set up with content copied in (Markdown and MDX files in content collections).
  • A list of the Gatsby source and transformer plugins in use, since each shapes the data queries see.

Step 1: Inventory Every Query

grep -rln "graphql\`" src gatsby-node.js | sort
grep -rn "useStaticQuery" src | wc -l

The site had 47 queries. Classifying them by what they did made the migration plan:

KindCountExampleReplacement
Page query for one Markdown node9blog post templategetEntry in a dynamic route
List query with filter and sort14blog index, tag pagesgetCollection + array methods
Site metadata static query11SEO componentImported config module
Image query (childImageSharp)8hero, author avatarsastro:assets <Image>
Cross-source join3author info on postsCollection reference()
createPages in gatsby-node2tags, paginationgetStaticPaths
Number of Gatsby GraphQL queries by kind Vertical bars. List queries with filter and sort 14. Site metadata static queries 11. Page queries for one node 9. Image queries 8. Cross-source joins 3. createPages queries 2. The 47 queries by kind 14 List + filter 11 Site metadata 9 Single page 8 Images 3 Joins 2 createPages
Classified from a grep of graphql tagged templates and useStaticQuery calls; every kind had a direct replacement.

Step 2: Site Metadata Becomes a Module

Eleven components ran the same static query for site.siteMetadata. In Astro that is a plain import:

// src/site.config.ts
export const site = { title: 'Acme Engineering', url: 'https://acme.dev', twitter: '@acme' };
---
import { site } from '../site.config';
---
<meta property="og:site_name" content={site.title} />

Eleven queries disappeared, and with them the most common source of "why is this field null" bugs in the old site.

Step 3: Page and List Queries Become Collection Queries

The blog post template's page query:

query ($id: String!) {
  markdownRemark(id: { eq: $id }) {
    html
    frontmatter { title date(formatString: "MMMM D, YYYY") tags author }
    timeToRead
  }
}

becomes a dynamic route with getStaticPaths and render:

---
// src/pages/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) => !data.draft);
  return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content, remarkPluginFrontmatter } = await render(post);
const date = post.data.date.toLocaleDateString('en-US', { dateStyle: 'long' });
---
<h1>{post.data.title}</h1>
<p>{date} · {remarkPluginFrontmatter.minutesRead}</p>
<Content />

timeToRead had come from gatsby-transformer-remark; a 12-line remark plugin computing reading time into frontmatter replaced it. formatString became toLocaleDateString — with an explicit timeZone: 'UTC', which the output comparison later showed was needed.

List queries — sort, filter, limit — became array methods on getCollection results. The 14 list queries became 14 short functions in a src/lib/posts.ts module, each unit-testable without a build.

Step 4: Joins Become References

Gatsby joined authors to posts through a @link directive on a YAML source. Astro collections express the same relationship with reference():

const authors = defineCollection({ loader: file('src/data/authors.yaml'), schema: z.object({ name: z.string(), avatar: z.string() }) });
const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: 'src/content/blog' }),
  schema: z.object({ title: z.string(), date: z.coerce.date(), author: reference('authors') }),
});

getEntry(post.data.author) resolves it. Unlike the GraphQL join, an unknown author ID fails the build with the post's file name instead of rendering a blank byline — the check found four posts with misspelled author IDs that had shown no author for years.

Mapping of Gatsby data layer concepts to Astro equivalents A matrix of six Gatsby concepts. Page query maps to getEntry in a dynamic route. List query maps to getCollection with array methods. Static query for site metadata maps to an imported module. childImageSharp maps to the Image component. The link directive maps to reference in a schema. createPages maps to getStaticPaths. Gatsby concepts and their Astro replacements Gatsby Astro Validation Page query getEntry in [slug].astro schema List query getCollection + filter/sort schema useStaticQuery site import site.config TypeScript childImageSharp Image / getImage build-time @link join reference() fails on unknown ID createPages getStaticPaths route types
Every replacement is a plain function call or import that can be tested outside the build.

Step 5: Image Queries Become the Image Component

hero { childImageSharp { gatsbyImageData(width: 1200, formats: [AUTO, WEBP, AVIF], placeholder: BLURRED) } }

became an image field in the collection schema and Astro's <Picture>:

<Picture src={post.data.hero} widths={[600, 900, 1200]} formats={['avif', 'webp']} alt={post.data.heroAlt} />

placeholder: BLURRED had no direct equivalent; the site dropped it after measuring that LCP was better without the blur-up swap. Fixing Blurry Images From astro:assets Resizing covers sizing choices.

Step 6: Compare Output Page by Page

Every migrated template was checked against Gatsby's output for the same URLs. A script extracted the main content text, title, meta description and image count from both builds and reported differences:

Difference foundPagesCause
Dates one day early212Missing timeZone: 'UTC'
No author byline4Misspelled author IDs (now build errors)
Reading time ±1 min380Different word counting; accepted
Missing related posts0—

Content From a Headless CMS

Three of the 47 queries read from a headless CMS through gatsby-source-contentful, which pulled every entry into Gatsby's data layer at build time. Without Gatsby, there are two options. The first is a content layer loader that calls the CMS's API directly and stores entries in an Astro collection with a schema, so templates query them like local content; this is what the site chose, using the approach in Loading Remote Content With Astro Content Layer Loaders. The second is to call the CMS's own GraphQL endpoint from getStaticPaths — reasonable for a handful of pages, but it scatters API calls across templates and loses the schema validation.

The loader approach also changed how content updates reach the site. Gatsby's source plugin fetched everything on each build, which took 70 seconds of the 6-minute build. The loader syncs only entries changed since the last build when the data store cache is restored, which took that step to about 4 seconds on content-only rebuilds triggered by the CMS webhook.

Moving Logic Out of gatsby-node

gatsby-node.js had grown to 380 lines over four years. Besides createPages, it held onCreateNode hooks that computed slugs and added fields, a schema customisation block that declared types to stop Gatsby inferring them wrongly, and redirect creation. Each of these had a natural home after the migration: slug computation moved into the glob loader's generateId option, field additions into the collection schema's transform, type declarations into Zod schemas (which are stricter and validated), and redirects into the host's redirect file. None of the logic was lost, but it was spread across the places a reader of the new codebase would look for it, instead of one file with a dozen lifecycle hooks.

Measured Impact

MeasureGatsby 4Astro 5Tool
GraphQL queries470grep
Data-layer plugins90package.json
Full build6 min 40 s1 min 05 sCI logs
Data access code~1,100 lines (queries + gatsby-node)~420 linescloc
Unit-tested data functions014Vitest
Build time and data access code before and after replacing GraphQL Paired bars. Full build went from 400 seconds to 65 seconds. Data access code went from 1,100 lines to 420 lines. Data layer plugins went from 9 to 0. Build and codebase size before and after Gatsby 4 Astro 5 Full build (s) 400 65 Data access lines 1100 420 Data-layer plugins 9 0
Build time from CI on the same runner; line counts from cloc over src and config files.

Pitfalls & Rollback

  • Porting queries one to one. Most become simpler; site metadata needs no query at all.
  • Losing transformer fields. timeToRead, excerpt and fields.slug came from plugins; reimplement them deliberately.
  • Date formatting without a timezone. GraphQL formatString worked in UTC; toLocaleDateString uses the machine's zone.
  • Skipping output comparison. Byline, date and image differences only show in output.
  • Rollback: migrate by section behind path routing, so any section can return to the Gatsby build while its queries are fixed.

Conclusion

Gatsby's GraphQL layer does not migrate, but nearly everything it did has a simpler replacement: imports for site metadata, collection queries for pages and lists, references for joins, the image component for sharp queries, and getStaticPaths for createPages. Replacing 47 queries removed nine plugins and 680 lines of data code, surfaced four hidden data errors, and cut the build from 6 minutes 40 seconds to just over a minute.

FAQ

Do I need GraphQL after leaving Gatsby?

Almost never. Gatsby used GraphQL as an internal data layer to join content from plugins. Astro, Next.js and Eleventy read content directly with functions, content collections or data files, which are simpler to debug. If content comes from a GraphQL API such as a headless CMS, keep querying that API, but drop Gatsby's own data layer.

How do I find every GraphQL query in a Gatsby site?

Search for graphql tagged templates and useStaticQuery calls in src, and for createPages queries in gatsby-node. Gatsby's build also writes extracted queries to the .cache directory. Listing them with the fields each uses gives the migration inventory.

What replaces gatsby-plugin-image and sharp queries?

The destination's image pipeline. In Astro, the Image component and astro:assets resize and convert images at build time from local imports or content collection image fields. In Next.js static export, a custom loader or prebuilt variants are needed. Map each childImageSharp query to the new component with equivalent widths and formats.

Can I migrate a Gatsby site incrementally?

Yes, by section. Serve the new generator's output for migrated paths and the old Gatsby build for the rest, behind one domain with path-based routing at the CDN. Each section moves when its queries are replaced and its output passes the comparison. This avoids a single risky cutover.

Filed underHow-tosGatsby