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:
| Kind | Count | Example | Replacement |
|---|---|---|---|
| Page query for one Markdown node | 9 | blog post template | getEntry in a dynamic route |
| List query with filter and sort | 14 | blog index, tag pages | getCollection + array methods |
| Site metadata static query | 11 | SEO component | Imported config module |
Image query (childImageSharp) | 8 | hero, author avatars | astro:assets <Image> |
| Cross-source join | 3 | author info on posts | Collection reference() |
createPages in gatsby-node | 2 | tags, pagination | getStaticPaths |
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.
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 found | Pages | Cause |
|---|---|---|
| Dates one day early | 212 | Missing timeZone: 'UTC' |
| No author byline | 4 | Misspelled author IDs (now build errors) |
| Reading time ±1 min | 380 | Different word counting; accepted |
| Missing related posts | 0 | — |
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
| Measure | Gatsby 4 | Astro 5 | Tool |
|---|---|---|---|
| GraphQL queries | 47 | 0 | grep |
| Data-layer plugins | 9 | 0 | package.json |
| Full build | 6 min 40 s | 1 min 05 s | CI logs |
| Data access code | ~1,100 lines (queries + gatsby-node) | ~420 lines | cloc |
| Unit-tested data functions | 0 | 14 | Vitest |
Pitfalls & Rollback
- Porting queries one to one. Most become simpler; site metadata needs no query at all.
- Losing transformer fields.
timeToRead,excerptandfields.slugcame from plugins; reimplement them deliberately. - Date formatting without a timezone. GraphQL
formatStringworked in UTC;toLocaleDateStringuses 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.
Related
- Parent: Migrating Between Static Site Generators — migration planning.
- Migrating from Gatsby to Astro — the full migration.
- Migrating from Gatsby to Next.js Static Export — the alternative destination.
- Content Collections vs the Eleventy Data Cascade — how other generators model content.
- Gatsby vs Astro for Content Sites in 2026 — whether to migrate at all.