12 min read

Gatsby Blog SEO Checklist for Gatsby 5

A practical Gatsby blog SEO checklist for Gatsby 5: siteUrl and trailingSlash, file-path slugs, the Head API instead of react-helmet, canonical and Open Graph tags, JSON-LD, sitemap and robots.txt plugins, redirects, deferred pages and a post-deploy curl check.

Blog on Gatsby? See how Blogger Dog publishes into Gatsby.

Why a Gatsby blog can look fine and still rank poorly

Gatsby renders every page to static HTML at build time, so a crawler gets the full article on the first request. That is a strong starting point, and it is why many developer blogs still run on Gatsby years after they were set up.

The problems are in what the HTML says. Most Gatsby blogs started from a starter and never touched the SEO component again. The official gatsby-starter-blog is a good example: its Seo component writes a title, a description, Open Graph and Twitter tags, but no canonical link, no og:url, no og:image, and og:type is website on every post. Its sitemap and robots.txt are not set up at all. None of this breaks the build, so the site looks fine to you and incomplete to Google.

This checklist covers the setup we would want on any Gatsby blog that is meant to bring in search traffic. It is written against Gatsby 5 (5.16.1 is the current release on npm) and the official plugin docs. If you run another stack, the Next.js blog SEO checklist, the Astro blog SEO checklist, the Hugo blog SEO checklist, the Jekyll blog SEO checklist and the Docusaurus blog SEO checklist cover the same ground for those tools. If the blog is hosted on GitLab Pages, the GitLab Pages blog SEO checklist covers the hosting side: the Pages job, custom domains and redirects.

1. Make sure your host serves every post

Gatsby 4 added Deferred Static Generation (DSG). A page created with defer: true is not built at gatsby build time; it is generated on the first request. The DSG reference is clear that this "requires a running NodeJS server", for example gatsby serve or a host with a Gatsby adapter such as gatsby-adapter-netlify (adapters arrived in Gatsby 5.12).

DSG is opt-in per page, but it is easy to copy from a tutorial or an older starter version (an early branch of the official starter marked every post deferred):


createPage({

  path: post.fields.slug,

  component: blogPost,

  context: { id: post.id, previousPostId, nextPostId },

  defer: true,

})

If you deploy the public/ folder to a plain static host (GitHub Pages, S3, Cloudflare Pages without an adapter), deferred posts have no Node server to render them. Drop defer: true so every post is built as HTML at build time. A blog with a few hundred posts builds quickly either way, and static HTML is exactly what you want crawlers to get.

2. Set siteUrl, trailingSlash and pathPrefix once

Three settings in gatsby-config decide every absolute URL your site prints:


// gatsby-config.js

module.exports = {

  siteMetadata: {

    title: "Example Engineering Blog",

    description: "Notes on building and running Example.",

    siteUrl: "https://example.com", // no trailing slash

  },

  trailingSlash: "always",

  // pathPrefix: "/blog", // only if the whole site lives under a subpath

  plugins: [

    // ...

  ],

}

  • siteMetadata.siteUrl is what the sitemap plugin reads by default; its docs say that unless you use resolveSiteUrl, "this needs to be set". Use it for canonical and Open Graph URLs too, so there is one source of truth.
  • trailingSlash (added in Gatsby 4.7) accepts always, never or ignore, and the default is always. Pick one and make sure your host redirects the other form to it. If /my-post and /my-post/ both return 200, you have two URLs for one page.
  • pathPrefix is only for sites served from a subpath, like example.com/blog. It only applies when you build with gatsby build --prefix-paths; forgetting the flag is a common reason assets and links 404 under a subpath. A GitHub Pages project site (username.github.io/repo) is the usual case; when it moves to a custom domain, drop the prefix and update siteUrl. The GitHub Pages custom domain guide covers the DNS, HTTPS and redirect side of that move.

3. Know where each post URL comes from

In most Gatsby blogs the URL does not come from the front matter. The starter's onCreateNode uses createFilePath from gatsby-source-filesystem, so content/blog/cut-build-time-in-half.md becomes /cut-build-time-in-half/. A slug: field in the front matter is ignored unless your gatsby-node reads it.

That has two consequences:

  • Renaming a file changes its URL. Treat file names as permanent once a post is live, or add a redirect (step 8).
  • Folder-per-post layouts (content/blog/my-post/index.md) produce /my-post/, but a stray content/blog/my-post/notes.md produces /my-post/notes/, a thin page nobody meant to publish. Keep drafts and notes outside the folder gatsby-source-filesystem reads, or filter them out in your createPages query.

If you would rather have the URL live in the front matter, read it explicitly and fall back to the file path:


exports.onCreateNode = ({ node, actions, getNode }) => {

  if (node.internal.type === "MarkdownRemark") {

    const fromFile = createFilePath({ node, getNode })

    const slug = node.frontmatter?.slug ? `/${node.frontmatter.slug}/` : fromFile

    actions.createNodeField({ node, name: "slug", value: slug })

  }

}

Decide this before you have traffic. Changing the URL scheme later means a redirect for every post.

4. Use the Head API, not react-helmet

Older Gatsby tutorials put meta tags in react-helmet through gatsby-plugin-react-helmet. Since Gatsby 4.19 there is a built-in Gatsby Head API: export a function named Head from a page or a createPage template, and Gatsby renders its tags into the static HTML. It receives location, params, data (your page query result) and pageContext, so it can use the same GraphQL data as the page.

A post template that covers what the starter leaves out:


// src/templates/blog-post.js

export const Head = ({ data, location }) => {

  const post = data.markdownRemark

  const { siteUrl, title: siteTitle } = data.site.siteMetadata

  const url = `${siteUrl}${location.pathname}`

  const description = post.frontmatter.description || post.excerpt

  const image = `${siteUrl}/og/${post.fields.slug.replace(/\//g, "")}.png`



  return (

    <>

      <title>{`${post.frontmatter.title} | ${siteTitle}`}</title>

      <meta name="description" content={description} />

      <link rel="canonical" href={url} />

      <meta property="og:type" content="article" />

      <meta property="og:url" content={url} />

      <meta property="og:title" content={post.frontmatter.title} />

      <meta property="og:description" content={description} />

      <meta property="og:image" content={image} />

      <meta name="twitter:card" content="summary_large_image" />

    </>

  )

}

Add siteUrl to the site { siteMetadata { ... } } part of the page query, and fields { slug } and excerpt to the post. Points that trip people up, all from the Head API docs:

  • Head only works in pages and templates, not in ordinary components. A shared Seo component is fine as long as a page's Head renders it.
  • Each page must define its own head; nothing carries over from the previous page.
  • Only title, meta, link, style, base, script and noscript are allowed.
  • Give a tag an id if a layout and a page might both render it; Gatsby keeps the last one with that id.

Point the og:image at a real 1200x630 PNG or JPG. Social platforms do not render SVG previews, and a missing image turns every share into a plain text link.

5. Set the page language

The current starter sets <html lang> site-wide in gatsby-ssr.js, but sites that started from an older starter or removed react-helmet often lost it. Since Gatsby 5.5 you can set it per page in Head with <html lang="en" />, or site-wide:


// gatsby-ssr.js

exports.onRenderBody = ({ setHtmlAttributes }) => {

  setHtmlAttributes({ lang: "en" })

}

Check that your site still has it. It is one line, and it helps both screen readers and search engines read the page in the right language.

6. Add BlogPosting structured data

JSON-LD is a data block, so it belongs in Head (the docs recommend the Gatsby Script component only for scripts that run). Add it next to the tags from step 4:


const jsonLd = {

  "@context": "https://schema.org",

  "@type": "BlogPosting",

  headline: post.frontmatter.title,

  description,

  datePublished: post.frontmatter.dateIso,

  dateModified: post.frontmatter.updatedIso || post.frontmatter.dateIso,

  mainEntityOfPage: url,

  image,

  author: { "@type": "Person", name: "Your Name" },

}



// inside Head

<script type="application/ld+json">{JSON.stringify(jsonLd)}</script>

The starter formats date for display (formatString: "MMMM DD, YYYY"), which is not a valid schema.org date. Query the field twice with an alias, dateIso: date alongside the formatted one, so the JSON-LD gets the raw ISO value. Structured data also helps answer engines pick out who wrote the post and when; our guide to getting a developer blog cited in AI answers goes into that side.

7. Generate a sitemap and a robots.txt that agree

Install gatsby-plugin-sitemap (6.16.0 at the time of writing). From its README:

  • It writes sitemap-index.xml at the site root, pointing at sitemap-0.xml (and more files every 45,000 URLs).
  • It "only generates output when run in production mode", so gatsby develop never shows it. Test with gatsby build && gatsby serve.
  • Every URL gets the same changefreq and priority by default. Google ignores those, but it does read lastmod, and the README warns that if lastmod is misrepresented Google will stop reading it. Use the serialize option to set a real date per post.

Then add a robots.txt. The common choice is gatsby-plugin-robots-txt, and here is the trap: its sitemap option defaults to ${siteUrl}/sitemap/sitemap-index.xml, a /sitemap/ folder that gatsby-plugin-sitemap does not create with its default output: "/". Set it explicitly:


{

  resolve: "gatsby-plugin-robots-txt",

  options: {

    sitemap: "https://example.com/sitemap-index.xml",

    policy: [{ userAgent: "*", allow: "/" }],

  },

},

The plugin also takes an env map keyed by GATSBY_ACTIVE_ENV (then NODE_ENV), so deploy previews can disallow everything while production allows crawling. Submit the sitemap index in Search Console once it is live. For RSS, gatsby-plugin-feed is already in the starter; keep it, and consider an llms.txt file too, as covered in our guide to llms.txt for developer blogs.

8. Redirect renamed posts on the host, not just in Gatsby

Gatsby has actions.createRedirect({ fromPath, toPath, isPermanent: true }), but the actions reference says redirects "must be implemented by your deployment platform". A hosting adapter turns them into real HTTP redirects. On a plain static host, createRedirect on its own does nothing a crawler can see.

Your options, best first:

  1. A host with an adapter or redirect rules (Netlify, Vercel, Cloudflare Pages _redirects) returning a 301.
  2. gatsby-plugin-meta-redirect, which writes small HTML files with a meta refresh. Google follows those, but a server 301 is the stronger signal.

Either way, keep the old URL out of the sitemap and update internal links to the new one.

9. Keep links crawlable and fields optional

Google's JavaScript SEO basics put it plainly: Google "can only discover your links if they are <a> HTML elements with an href attribute". Gatsby's <Link> renders a real <a href>, so use it for every post link, tag page and pagination link. Avoid onClick navigation on a div or button for anything you want crawled.

A blog index that only renders the latest 10 posts and loads the rest with a "Load more" button hides older posts from crawlers. Paginate with real URLs (/blog/2/, /blog/3/) built in createPages, and link each post to its previous and next posts as the starter does.

The quiet failure in Gatsby is the schema. If a template queries frontmatter { featuredImage } and one post has no image, the build can fail or the page can render broken. The starter avoids this with createSchemaCustomization, declaring the front matter type so missing fields come back as null. Do the same for every optional field your templates use, and make the template handle null.

10. Check the live page, not public/

A clean gatsby build proves the HTML exists on your machine. It does not prove the host serves it. After each deploy, check the real URL:


URL="https://example.com/gatsby-seo-checklist/"

curl -s -o /dev/null -w "%{http_code}\n" "$URL"

curl -s "$URL" | grep -o '<link rel="canonical"[^>]*>'

curl -s "$URL" | grep -c '<h1'

curl -s https://example.com/sitemap-0.xml | grep -c "$URL"

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" "${URL%/}"

You want a 200, a canonical equal to the URL, exactly one H1, the URL in the sitemap, and the slashless variant redirecting to the slash version (or the reverse, if you chose trailingSlash: "never"). A 404 usually means a deferred page on a static host (step 1) or a file name that produced a different slug (step 3). This is the idea behind live URL verification for GitHub blog publishing: a post only counts once its public page proves it.

The checklist in one place

AreaSetting or fileWhat good looks like
RenderingcreatePageNo defer: true on a static-only host
HostsiteUrl, trailingSlash, pathPrefixOne production origin, one slash style, prefix only with --prefix-paths
URLsonCreateNode, file namesSlugs decided early; no stray files in the posts folder
Head tagsexport const HeadTitle, description, canonical, og:url, og:image, og:type article
Languagegatsby-ssr.js<html lang> set
Structured dataJSON-LD in HeadBlogPosting with ISO dates
Sitemapgatsby-plugin-sitemapsitemap-index.xml live, real lastmod
Robotsgatsby-plugin-robots-txtSitemap path matches the real file; previews blocked
Renamed postshost redirectsOld URLs return 301 to the new one
Links and schema<Link>, createSchemaCustomizationPaginated <a href> links; optional fields typed
Verificationcurl after deploy200, matching canonical, one H1, in the sitemap

FAQ

Is Gatsby still good for SEO?

Yes, for a blog that is built to static HTML. Pages arrive fully rendered, and Gatsby 5 still receives releases (5.16 is current on npm). The gaps are configuration: a starter Seo component without canonical or image tags, no sitemap or robots.txt, and deferred pages on a host that cannot render them.

Should I still use react-helmet with Gatsby?

Not on Gatsby 4.19 or later. The built-in Head API renders tags into the static HTML without an extra plugin. Move the tags from your Helmet Seo component into a Head export, then remove gatsby-plugin-react-helmet.

Why is my Gatsby sitemap missing?

gatsby-plugin-sitemap only writes files in production builds, so it never appears under gatsby develop. It also needs siteMetadata.siteUrl. Run gatsby build && gatsby serve and open /sitemap-index.xml.

Why does my Gatsby post return 404 on GitHub Pages?

The most common causes are defer: true in createPage (DSG needs a Node server, which GitHub Pages does not run), a missing --prefix-paths flag for a project site under a subpath, or a renamed file that changed the slug.

Where should Gatsby blog posts live?

In the folder gatsby-source-filesystem reads for posts, usually content/blog. Our Gatsby integration page explains how Blogger Dog detects a Gatsby site and writes posts there.

Keep the posts coming

A well-configured Gatsby blog with no new posts is still a quiet blog. Blogger Dog researches and writes cited SEO and AEO posts, commits them into the folder your gatsby-node already reads as slug.md or slug.mdx with title, date, description, tags and slug front matter, so the file-path slug and the front matter agree. It only counts a post once its public URL is live on your host. It works the same way for Next.js, Astro, Hugo, Jekyll and Docusaurus, on GitHub or GitLab.

You get 3 posts free, then plans start at $19/month. See pricing and plans.

Put your blog on autopilot

Blogger Dog researches, writes and commits SEO/AEO posts like this one straight into your GitHub or GitLab repo on a schedule. Your first 3 posts are free, then plans start at $19/month.

More from the archive