Gatsby SEO

How to optimize a Gatsby site for search — Gatsby's four rendering paths (SSG, DSG, SSR, client-only) and what each means for crawlability, the modern Head API vs legacy react-helmet, sitemaps, canonicals, image SEO, the React-bundle CWV cost, and the Netlify-era maintenance risk.

First published: Jun 26, 2026 · Last updated: Jul 18, 2026 · Advanced
demand #1 in Static Site Generators#31 in Platform SEO#243 in Technical SEO#339 on the site
1 evidence signal on this page

Gatsby has four rendering options: SSG (the default — pages pre-rendered to static HTML at gatsby build), DSG (deferred static generation on first request), SSR (server-side rendering per request via Gatsby Functions), and client-only routes (rendered entirely in the browser). Most Gatsby sites lean on SSG, which gives crawlers fully rendered HTML on the first fetch with no rendering queue to wait on — a strong SEO baseline, far better than a pure client-side React app. But that baseline isn't universal: DSG and SSR pages generate HTML outside the build step and need their own production checks, and client-only routes shouldn't be assumed to expose route-specific content in the initial HTML at all. Whichever path serves a page, Gatsby still ships the full React runtime (~200KB+) and hydrates it client-side — a Core Web Vitals cost, not a crawlability one. The current way to manage metadata is the built-in Gatsby Head API (v4.19+), which works across all four rendering paths and replaces gatsby-plugin-react-helmet. The recurring pitfalls are double canonical tags, draft/orphan pages leaking into sitemaps, missing alt text, the production-only sitemap gotcha, DSG/SSR/client-only routes needing explicit sitemap and production-behavior checks, and a framework whose maintenance has slowed sharply since Netlify's 2023 acquisition.

TL;DR — Gatsby has four rendering options — SSG (the default), DSG, SSR, and client-only routes — and they don’t all put content in the raw HTML the same way. SSG pre-renders to static HTML at build time (gatsby build), so content is in the raw HTML before the first crawler request — no Web Rendering Service, no rendering queue. DSG defers generation to the first request; SSR generates per request on a server; client-only routes render nothing route-specific until the browser runs the JavaScript. Whichever path serves a page, Gatsby hydrates a full React bundle (~200KB+) on the client, which is a Core Web Vitals cost, not a crawlability one. The current metadata approach is the built-in Gatsby Head API (v4.19+), which works across all four rendering paths and replaces gatsby-plugin-react-helmet. The recurring failure modes are double canonical tags (Head API + react-helmet both firing), draft/orphan pages leaking into sitemaps, the production-only sitemap gotcha, missing alt text on GatsbyImage, trailing-slash inconsistency, and DSG/SSR/client-only routes that never got a production check. And the elephant in the room: since Netlify’s 2023 acquisition, Gatsby’s maintenance has slowed sharply.

Gatsby’s four rendering options — and why they matter for SEO

Google processes JavaScript pages in three phases — crawling, then rendering, then indexing — and rendering happens in a separate pass from a queue using headless Chromium. Google’s own guidance is blunt about why you shouldn’t lean on that: “Server-side or pre-rendering is still a great idea because it makes your website faster for users and crawlers, and not all bots can run JavaScript.”

A Gatsby site isn’t universally build-time static HTML — Gatsby supports four distinct rendering paths, chosen per page or template Evidence for this claim gatsby build writes production output, including generated HTML, to the public directory. Scope: Gatsby production builds. Confidence: high · Verified: Gatsby CLI: build :

  • SSG (Static Site Generation) — the default. gatsby build emits fully rendered static HTML into /public for the page. Googlebot’s first-wave raw-HTML fetch already contains the complete content — text, links, metadata. This is the safe, low-risk path, and it’s what most Gatsby pages use.
  • DSG (Deferred Static Generation). Generation is deferred until the page’s first request rather than happening for every page at build time — useful for sites with huge numbers of low-traffic pages where a full build would be slow. The build alone does not tell you what a crawler sees; the page’s HTML doesn’t exist until something requests it, so first-request and cached behavior need their own check, not just a build log.
  • SSR (Server-Side Rendering). The page is rendered per request, using request-time data, via Gatsby Functions. Because it runs at request time, SSR pages need production checks a build-time page doesn’t: response status, caching headers, timeout behavior, and what a crawler sees on an empty or error response — none of that is visible from a successful local build.
  • Client-only routes. These render entirely in the browser and don’t get route-specific content in the initial HTML — the same profile as a plain client-side React app. Don’t assume Google (or any crawler) sees anything page-specific here until JavaScript runs; treat these as JS-dependent by design, which is fine for gated/authenticated content but wrong for anything you want indexed with content.

React hydration (ReactDOMClient.hydrateRoot()) happens client-side on top of whichever of these produced the HTML, purely to add interactivity — that part is the same regardless of rendering path, and is a separate concern from which path generated the page (more on hydration cost below).

Contrast SSG/DSG/SSR with a pure client-side React app, which serves an empty <div id="root"> and depends on the browser (or the renderer) to build the page. Most Gatsby pages ship meaningful HTML by default — that’s the indexability win, and it’s real, but it’s a per-page property, not a framework guarantee. Rendering options and image tooling don’t guarantee Core Web Vitals, indexing, or rankings on their own; verify the actual production output for whichever path a route uses.

The catch with SSG/DSG/SSR alike is a performance one, not a crawlability one: Gatsby ships the full React runtime to every page and re-hydrates it. More on that, and the Astro comparison, below.

Gatsby Head API vs gatsby-plugin-react-helmet

This is the single most important “are you doing it the modern way?” question in Gatsby SEO.

The legacy approach — gatsby-plugin-react-helmet. For years, the standard way to set <title>, meta description, and other head tags was the react-helmet library plus this plugin. The plugin’s job was to give react-helmet SSR support — without it, your title/meta tags would only appear after JS execution, not in the raw HTML, which defeats the point. It works, but it has known issues with React Hooks and concurrent rendering, plus a background-tab title bug you patch with defer={false}.

The modern approach — the Gatsby Head API (v4.19+). Gatsby now has a built-in way to add head elements: a named Head export from any page or template file. Evidence for this claim Gatsby pages and templates can export a named Head function to add head elements. Scope: Gatsby Head API. Confidence: high · Verified: Gatsby: Head API

export const Head = () => (
  <>
    <title>Page Title</title>
    <meta name="description" content="..." />
  </>
)

It receives useful props — location.pathname, params, data (from the page’s GraphQL query), and pageContext — and it deduplicates tags that share an id prop (last one wins), though running two different head-tag mechanisms at once (Head API plus a leftover react-helmet call) can still conflict even with deduplication. It works only in page files and templates, not in arbitrary components. The advantages over react-helmet: no third-party bundle, no Provider wrapper, deterministic tag order with React 18 streaming. Use the Head API for all new projects, and plan a migration for existing ones.

The Head API works the same way across all four rendering paths — SSG, DSG, SSR, and client-only routes all support a Head export. What differs is when its output lands in HTML a crawler can fetch: on SSG it’s baked in at build time; on DSG and SSR it’s generated at first-request or per-request time; on a client-only route it isn’t in the initial HTML at all. Don’t assume “I added a Head export” is equivalent to “this is in the raw HTML for every route” — check the actual production output (view-source: or curl) for whichever path each route uses, not just one representative page.

The “meta tags in DevTools but not in source” bug. A classic Gatsby SEO symptom: your title/meta tags show up in Chrome DevTools but are missing from view-source:. The cause is that DevTools shows the hydrated DOM (after JS runs), while view-source shows the raw HTML. If your tags only appear in DevTools, your SEO component is rendering client-side instead of being baked into Gatsby’s static output — usually because it’s used as a regular component rather than as (or inside) the page’s Head export. Always verify in raw HTML, not DevTools.

Wiring up an SEO component (the GraphQL data layer)

Gatsby’s data layer is GraphQL, and it’s how you feed metadata into your pages.

  • useStaticQuery pulls global defaults from siteMetadata (title, description, siteUrl) defined in gatsby-config.js.
  • Page-level GraphQL queries pass a data prop straight to the Head export — no extra wiring needed.
  • The standard pattern is prop || siteMetadata fallback: use the per-page value if it exists, otherwise the site default.

A Head export that takes page data looks like:

export const Head = ({ data }) => (
  <>
    <title>{data.post.title}</title>
    <meta name="description" content={data.post.excerpt} />
  </>
)

Sitemaps: gatsby-plugin-sitemap (and its traps)

Install gatsby-plugin-sitemap and configure it in gatsby-config.js. A few things trip people up:

  • It generates sitemap-index.xml, not /sitemap.xml. Submit the index URL in Google Search Console — don’t submit /sitemap.xml and expect it to resolve.
  • It only runs in production builds. It does nothing in gatsby develop. To test it, run gatsby build && gatsby serve. People file “my sitemap is missing” reports that are really just “I never ran a production build.”
  • createLinkInHead: true by default adds the sitemap reference to the HTML head automatically.
  • It always excludes /dev-404-page, /404, and /offline-plugin-app-shell-fallback.
  • <priority> and <changefreq> are ignored by Google — the plugin docs say so directly. Focus on an accurate <lastmod> instead.
  • entryLimit defaults to 45,000 URLs per file.

Excluding drafts is your job. Gatsby builds everything it finds, so draft content sails straight into the sitemap unless you filter it. Do that in gatsby-node.js with a GraphQL filter (e.g. excluding entries without a publish date), not by hiding it in a React component — by then it’s already built and listed.

DSG, SSR, and client-only routes need an explicit sitemap decision. gatsby-plugin-sitemap reflects what it can see at build time. An SSG page is straightforward — it exists as a static file, so it’s naturally sitemap-eligible. A DSG page’s HTML doesn’t exist yet at build time (it generates on first request), an SSR page never has fixed HTML at all, and a client-only route has no route-specific content to index in the first place. Don’t assume any of these are in the sitemap (or should be) just because the route exists — decide, page by page, whether it belongs, and verify the generated sitemap-index.xml actually reflects that decision rather than a build-time guess.

robots.txt: gatsby-plugin-robots-txt

gatsby-plugin-robots-txt generates robots.txt at build time. The useful detail for SEO is environment awareness: it reads process.env.GATSBY_ACTIVE_ENV then process.env.NODE_ENV, so you can serve different rules per environment. The classic use is blocking crawlers on Netlify preview/branch deploys so your staging URLs don’t get accidentally indexed, while leaving production open.

Canonical URLs (and the double-canonical bug)

Two viable approaches:

  1. gatsby-plugin-canonical-urls adds a <link rel="canonical"> to every page. Set stripQueryString: true so /blog?tag=foo and /blog don’t get treated as separate canonicalized pages — recommended for most sites.
  2. The Head API, setting canonicals yourself from location.pathname:
export const Head = ({ location }) => (
  <link rel="canonical" href={`https://example.com${location.pathname}`} />
)

The double canonical bug. This is a known, easy-to-hit issue: if you use gatsby-plugin-canonical-urls and a react-helmet canonical tag at the same time, you emit two <link rel="canonical"> tags. Pick one mechanism. (If you’re on react-helmet, gatsby-plugin-react-helmet-canonical-urls is the helmet-aware option; on the Head API, set the canonical there and drop the plugin.)

Trailing slashes. Gatsby pages can be reachable with and without a trailing slash, and Gatsby’s <Link> component uses client-side History API routing — which bypasses server-side 301 redirects you’d normally use to normalize trailing slashes. Decide on one form, enforce it at the host/CDN level, and keep canonicals consistent with it.

Image SEO: gatsby-plugin-image

gatsby-plugin-image is genuinely one of Gatsby’s strengths. Two components:

  • StaticImage — for images whose path is known and hardcoded at build time.
  • GatsbyImage — for dynamic images coming from GraphQL.

What it does automatically: multiple sizes, WebP/AVIF formats, lazy loading, and breakpoints (750/1080/1366/1920px). It also generates placeholders (blurred, dominant color, or traced SVG) that reserve space and prevent Cumulative Layout Shift — a Core Web Vitals metric. Because dimensions are set, you avoid CLS, and modern formats plus lazy loading help LCP.

The one thing it does not do is write alt text. That’s on you, every time — missing alt text on GatsbyImage is one of the most common Gatsby SEO oversights. (Migrating from the old gatsby-image package? There’s a codemod: npx gatsby-codemods gatsby-plugin-image.)

Structured data (JSON-LD)

Google’s preferred structured-data format is JSON-LD, and the clean way to add it in modern Gatsby is via the Head API with a script tag:

export const Head = ({ data }) => (
  <script type="application/ld+json">
    {JSON.stringify({
      "@context": "https://schema.org",
      "@type": "Article",
      "headline": data.post.title,
    })}
  </script>
)

gatsby-plugin-next-seo offers pre-built JSON-LD components if you’d rather not hand-roll them. One frequent confusion to clear up: gatsby-plugin-manifest is not a structured-data plugin — it generates the PWA web app manifest (icons, theme color), nothing to do with schema.

The React bundle and Core Web Vitals

Here’s Gatsby’s real weakness relative to the zero-JS generators.

  • Full hydration (the Gatsby 1–4 default) hydrates the entire React tree and ships a 200KB+ React runtime to every page. The HTML is pre-rendered, so this doesn’t hurt crawlability — but it absolutely affects load speed and CWV.
  • Partial hydration (Gatsby 5, experimental) hydrates only components marked "use client" and leaves the rest as static HTML, cutting the JS shipped and directly improving TTI and CWV. The limitations are real: production builds only, still beta, and incompatible with emotion, styled-components, and gatsby-plugin-offline.

The takeaway: Gatsby’s JS payload is a performance problem, not an indexability one. Googlebot still renders JS to assess page-experience signals, so the bundle can cost you on CWV even though your content indexes fine.

Gatsby vs Astro for SEO

If you’re choosing a static framework today, this is the comparison that matters most for SEO.

DimensionGatsbyAstro
JS shipped to browser200KB+ (full React runtime)~5KB (interactive islands only)
Rendering modelSSG → SPA (full hydration)SSG → MPA (zero hydration by default)
Build speed (40 pages)2–3 minutesUnder 10 seconds
SEO plugin ecosystemMature (gatsby-plugin-*)Growing
Crawl-budget impactHigher (more JS for Google to render)Lower
Framework futureUncertain (Netlify ownership, slowed activity)Active, growing

Both pre-render indexable HTML — that’s a wash. The difference is the JS tax: Astro’s islands ship a fraction of the JavaScript, which a Vaihe comparison frames as “Reduced JavaScript execution conserves crawl budget and accelerates page scanning.” (One historical caveat on the other side: Astro’s image handling lacked automatic width/height at the time of that comparison, which produced Lighthouse warnings — check current Astro docs, as it may be resolved.)

For context on how the whole field compares, see the static site generators hub.

The honest part: Gatsby’s maintenance trajectory

I won’t sugarcoat this, and I won’t catastrophize it either.

Netlify acquired Gatsby Inc. in February 2023. Gatsby Cloud was sunset and customers were moved to Netlify; Netlify stated the acquisition would “not impact Gatsby JS.” Since then, activity has slowed markedly. A widely-read community GitHub discussion (#39062) argues Gatsby is effectively abandoned — minimal commits, no React 19 support, a 2024 roadmap that wasn’t delivered, and the telemetry service shut down. Maintainers have framed the current state as security fixes, limited dependency updates, and low-hanging-fruit bug fixes.

What that means for SEO teams. For an existing Gatsby site, none of this is an emergency — it builds, it indexes, it works. The risk is ecosystem decay over time: SEO depends on plugins (gatsby-plugin-sitemap, image, canonical-urls), and aging plugins (e.g. gatsby-source-shopify facing API deprecation) can eventually break in ways that quietly degrade indexing. For a new project, weigh that seriously — the frameworks people are migrating to are Astro and Next.js.

Production checklist: verify every rendering path

A local gatsby develop session or even a clean gatsby build log doesn’t prove what a crawler actually receives. Because SSG, DSG, SSR, and client-only routes each generate HTML differently, check production behavior per path rather than assuming one representative page covers all of them:

  • Raw HTML content. For each rendering path in use, fetch a real production URL with curl -s <url> or view-source: — not DevTools — and confirm the content, title, and meta tags a crawler would see are actually there.
  • Metadata (Head export output). Confirm the Head export’s tags land in that raw HTML for the path in question. This is where DSG/SSR differ most from SSG: the tags exist in your source either way, but only a production fetch proves they made it into the response.
  • HTTP status. Check the response status code on production, especially for SSR and DSG routes — a page that renders fine locally can 500 in production on first request or under load in ways a build never surfaces.
  • Caching behavior. SSG output is a static file with predictable caching. DSG caches after the first request — confirm the second request is fast and correct, not just the first. SSR responses depend on your caching headers and hosting layer; verify stale or per-request content isn’t served to the wrong visitor.
  • Failure and empty-state behavior. For SSR and DSG pages backed by request-time or first-request data, check what a crawler sees if that data fetch fails or returns empty — an unhandled error state is not the same page you tested locally with good data.
  • Sitemap generation and exclusions. Re-confirm this only happens on a production build (gatsby build && gatsby serve, never gatsby develop), and that the exclusions you expect — drafts, client-only routes, anything you decided shouldn’t be listed — are actually absent from the generated sitemap-index.xml, not just absent from your intent.

None of this is optional per rendering path — a working gatsby build proves SSG output, not DSG, SSR, or client-only behavior.

Common Gatsby SEO mistakes

  1. Still using gatsby-plugin-react-helmet — legacy; migrate to the Head API.
  2. Meta tags in DevTools but not in page source — the SEO component is rendering client-side; check view-source:, not DevTools.
  3. Draft content in sitemaps — filter drafts in gatsby-node.js with GraphQL, not in a React component.
  4. Orphan pages from src/pages — Gatsby auto-routes everything there; stale files build and land in the sitemap.
  5. Double canonical tagsgatsby-plugin-canonical-urls + react-helmet both firing.
  6. Trailing-slash inconsistency<Link> client-routing bypasses server-side trailing-slash redirects.
  7. Not stripping query strings from canonicals — set stripQueryString: true.
  8. Alt text omitted on GatsbyImage — it isn’t auto-generated.
  9. Submitting /sitemap.xml — the real file is /sitemap-index.xml.
  10. Testing the sitemap in gatsby develop — it only generates on gatsby build.
  11. Noindex toggled by React state — Google may have already processed the raw HTML; and when it sees noindex in raw HTML it may skip rendering entirely. Keep noindex decisions in static HTML or server headers.

For the JavaScript-rendering fundamentals behind all of this, see the parent JavaScript SEO hub.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin an expert quote first.