A Gatsby build fails in CI with Cannot query field "description" on type "MarkdownRemarkFrontmatter". It built fine on your laptop ten minutes ago. Nobody changed a template. What changed is the content: the one post that had a description in its frontmatter got edited, or the CMS returned a record with an empty field, and Gatsby's schema inference quietly produced a different schema than it did last time.
This is the most common class of "it works locally" failure we get called in on, and it is entirely preventable. Gatsby infers the GraphQL schema from the data it happens to see. If you never tell it what the shape should be, the shape is whatever today's content says it is. This tutorial covers pinning the schema down with createSchemaCustomization, getting nullability right, and generating TypeScript types so your editor flags a bad query before CI does.
Everything here is Gatsby 5, and works on Gatsby 4.
1. Look at what Gatsby actually inferred
Before changing anything, read the current schema. Turn on typegen in config:
// gatsby-config.js
module.exports = {
graphqlTypegen: {
typesOutputPath: 'src/gatsby-types.d.ts',
generateOnBuild: true,
},
// ...plugins
};
Then run a develop pass and inspect the artifacts:
npx gatsby develop
# GraphiQL, with a schema explorer:
# http://localhost:8000/___graphql
# and the dumped SDL:
less .cache/schema.gql
.cache/schema.gql is the file to read. Search it for your content type — MarkdownRemarkFrontmatter, ContentfulBlogPost, SanityPost — and look at two things: which fields exist at all, and which are marked ! (non-null). You will usually find that almost nothing is non-null, and that one or two fields you rely on are missing entirely because no record currently populates them.
That second case is the CI failure. The field is not missing from your content model; it is missing from this build's data.
2. Declare the types explicitly
createSchemaCustomization hands Gatsby the type definitions instead of letting it guess:
// gatsby-node.js
exports.createSchemaCustomization = ({ actions }) => {
const { createTypes } = actions;
createTypes(`
type MarkdownRemark implements Node {
frontmatter: Frontmatter
fields: MarkdownRemarkFields
}
type Frontmatter {
title: String!
date: Date! @dateformat
description: String
author: String
draft: Boolean
tags: [String!]
hero: File @fileByRelativePath
}
type MarkdownRemarkFields {
slug: String!
}
`);
};
Read that carefully, because the nullability is the whole point:
title: String!— every post must have one. If one does not, the build fails at the source with a message naming the node, rather than a template blowing up onundefinedthree steps later.description: String— optional, and honestly optional. The template must handlenull.tags: [String!]— the list may be absent, but if present it contains no null entries.@dateformatgives the field itsformatStringandfromNowarguments.@fileByRelativePathturns a string path in frontmatter into a realFilenode, which is whatgatsby-plugin-imageneeds downstream.
The @dontInfer directive on a type switches inference off entirely, so an undeclared field simply does not exist. That is the end state you want — consistent and loud. Add it after you have listed every field you query, though, or you will spend an afternoon adding them back one failed build at a time. Good order: declare types without @dontInfer, get a green build, then add it and fix everything in one pass.
3. Fix "the field exists but is null everywhere" with resolvers
The classic version: draft: Boolean is declared, but only three posts actually carry draft: true. Every other node resolves to null, not false, so filter: { frontmatter: { draft: { eq: false } } } silently returns nothing and your blog index goes empty.
Declaring a type does not populate it. Supply a default with a resolver:
exports.createSchemaCustomization = ({ actions, schema }) => {
actions.createTypes([
schema.buildObjectType({
name: 'Frontmatter',
fields: {
title: 'String!',
date: { type: 'Date!', extensions: { dateformat: {} } },
description: 'String',
draft: {
type: 'Boolean!',
resolve: (source) => source.draft ?? false,
},
tags: {
type: '[String!]!',
resolve: (source) => source.tags ?? [],
},
},
}),
]);
};
buildObjectType is the programmatic form of the SDL above, and createTypes accepts both forms in one array, so mix them freely. Once draft is Boolean! with a resolver, the filter behaves the way every reader of that query already assumes it behaves, and an empty list is an empty list rather than null.
The same trick removes a lot of template noise around optional images: resolve the fallback once at the schema level instead of writing post.frontmatter.hero?.childImageSharp?.gatsbyImageData in six components.
4. Generate TypeScript types and actually use them
With graphqlTypegen on, a develop or build run writes src/gatsby-types.d.ts with a type for every named query in the codebase. Unnamed queries get nothing, so name them all.
// src/templates/post.tsx
import * as React from 'react';
import { graphql, type HeadFC, type PageProps } from 'gatsby';
export const query = graphql`
query BlogPostBySlug($slug: String!) {
markdownRemark(fields: { slug: { eq: $slug } }) {
html
frontmatter {
title
description
date(formatString: "MMMM D, YYYY")
}
}
}
`;
const PostTemplate: React.FC<PageProps<Queries.BlogPostBySlugQuery>> = ({ data }) => {
const post = data.markdownRemark;
if (!post) return null;
return (
<article>
<h1>{post.frontmatter?.title}</h1>
<time>{post.frontmatter?.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
</article>
);
};
export const Head: HeadFC<Queries.BlogPostBySlugQuery> = ({ data }) => (
<>
<title>{data.markdownRemark?.frontmatter?.title}</title>
{data.markdownRemark?.frontmatter?.description && (
<meta name="description" content={data.markdownRemark.frontmatter.description} />
)}
</>
);
export default PostTemplate;
Generated types live in a global Queries namespace — Queries.BlogPostBySlugQuery matches the query name — so there is nothing to import.
Here is where step 2 pays off visibly. Because you declared title: String!, the generated type is string, not string | null, and TypeScript stops demanding a guard on a field that cannot be missing. Sloppy schema nullability shows up as optional-chaining noise in every template; tight nullability makes templates read cleanly.
useStaticQuery takes the same parameter:
const { site } = useStaticQuery<Queries.SiteMetaQuery>(graphql`
query SiteMeta {
site { siteMetadata { title siteUrl } }
}
`);
Either commit src/gatsby-types.d.ts or generate it in CI before tsc runs. Pick one and write it down, because a missing types file greets a fresh clone with a wall of "Cannot find namespace 'Queries'" errors.
5. Gate it in CI
Types nobody checks are decoration. Three cheap steps catch essentially all of this bug class:
# .github/workflows/ci.yml (excerpt)
- name: Install
run: npm ci
- name: Build (validates schema, regenerates types)
run: npx gatsby build
env:
CI: true
- name: Typecheck
run: npx tsc --noEmit
- name: Fail on schema drift
run: git diff --exit-code src/gatsby-types.d.ts
The last step is the interesting one. If the build regenerates the types file and it differs from what is committed, either the schema genuinely changed — a real event worth a code review — or someone edited a query without regenerating. Both deserve a human look.
One caveat: if your content lives in a CMS whose schema legitimately shifts between builds, drop the drift check and rely on explicit types plus @dontInfer to keep the schema stable instead. Keep the typecheck either way.
If your content is file-based, add a source-level validation step before the build too. Twenty lines reading every Markdown file with gray-matter and asserting required frontmatter gives a far better error message than GraphQL does, and runs in under a second.
6. The CMS case is worse, not better
With Contentful, Sanity, or any headless CMS, inference is riskier because the schema now depends on the state of a remote system at build time. An optional field that no entry currently populates does not exist in the Gatsby schema, so a query referencing it fails — in production, triggered by an editor hitting publish, with nobody watching.
Same fix, applied to the source plugin's types:
exports.createSchemaCustomization = ({ actions }) => {
actions.createTypes(`
type ContentfulBlogPost implements Node {
title: String!
slug: String!
excerpt: String
publishedAt: Date! @dateformat
heroImage: ContentfulAsset
}
`);
};
Declaring a field the CMS never returns is fine — it resolves to null and the query keeps working. That is exactly the guarantee you want: the build no longer depends on whether an editor filled in an optional field.
Some source plugins ship their own createSchemaCustomization and will conflict. If you see Type "X" already exists, extend the existing type rather than redeclaring it, or check whether the plugin exposes a schema option before fighting it.
What this buys you
A build that fails for the same reason every time, at the earliest possible step, naming the offending node. Templates that are not eighty percent optional chaining. And an editor that catches a typo'd field name as you type it rather than eleven minutes into a CI run.
On a site nobody touches for months at a stretch — which describes most of the Gatsby sites we maintain — this matters more than it sounds. The build that breaks is almost never the one you were watching.
If you have a Gatsby build that fails unpredictably in CI, or an inherited schema you do not trust, get in touch. A schema audit is usually a short engagement with a permanent result.