+1 (415) 779-8456

Replacing react-helmet with the Gatsby Head API: A Safe Migration for Titles, Meta, and Canonicals

Many Gatsby sites still depend on react-helmet and gatsby-plugin-react-helmet for titles and meta tags. Both are unmaintained-in-spirit, add runtime JavaScript, and can behave oddly with React 18 and 19. Gatsby 4.19+ ships a built-in Head API that renders tags straight into static HTML at build time. This tutorial walks through a low-risk migration.

Why migrate

  • Head tags are produced at build time with no extra client library.
  • It works with React 18/19 concurrent rendering.
  • You drop two dependencies and one plugin.

1. Inventory current usage

grep -rn "react-helmet" src gatsby-config.js

Note every page and template that renders <Helmet>, and any tags set conditionally, such as noindex or alternate-language links.

2. Build one reusable SEO component

Unlike Helmet, the Head API is an export on a page or template file. A shared component keeps defaults in one place:

// src/components/seo.js
import * as React from "react"
import { useSiteMetadata } from "../hooks/use-site-metadata"

export const SEO = ({ title, description, pathname, noindex, children }) => {
  const { title: siteTitle, description: defaultDesc, siteUrl } = useSiteMetadata()
  const fullTitle = title ? `${title} | ${siteTitle}` : siteTitle
  const url = new URL(pathname || "/", siteUrl).toString()
  return (
    <>
      <title>{fullTitle}</title>
      <meta name="description" content={description || defaultDesc} />
      <link rel="canonical" href={url} />
      <meta property="og:title" content={fullTitle} />
      <meta property="og:url" content={url} />
      {noindex && <meta name="robots" content="noindex" />}
      {children}
    </>
  )
}

useSiteMetadata is a useStaticQuery hook, which is allowed inside Head.

3. Export Head from each page

// src/pages/pricing.js
import { SEO } from "../components/seo"

export const Head = ({ location }) => (
  <SEO title="Pricing" description="Plans and rates." pathname={location.pathname} />
)

For template pages, use data and pageContext from the Head props:

export const Head = ({ data, location }) => (
  <SEO title={data.post.title} description={data.post.excerpt} pathname={location.pathname} />
)

The query that feeds the page is available to Head, so no second query is needed.

4. Limits to know about

  • Head only accepts title, meta, link, style, script, base, and noscript elements.
  • You cannot set attributes on <html> or <body> from Head. Use onRenderBody with setHtmlAttributes in gatsby-ssr.js for lang.
  • Head components must not use context providers from your layout, since they render outside the page tree.
// gatsby-ssr.js
export const onRenderBody = ({ setHtmlAttributes }) => {
  setHtmlAttributes({ lang: "en" })
}

5. Remove Helmet

npm uninstall react-helmet gatsby-plugin-react-helmet

Delete the plugin from gatsby-config.js. Do this only after every page exports Head, or those pages will lose their titles.

6. Verify in built HTML

Build and inspect output rather than trusting the dev server:

gatsby build && grep -L "<title" public/**/index.html

Any file listed has no title. Also check for duplicates:

grep -c 'rel="canonical"' public/index.html   # expect 1

Add this as a CI step so regressions fail the build. A Playwright check that reads document.title and the canonical link on your top templates is a good complement.

Migration checklist

  1. Inventory Helmet usage.
  2. Create the shared SEO component.
  3. Export Head on every page and template.
  4. Move lang to gatsby-ssr.js.
  5. Uninstall Helmet and its plugin.
  6. Diff titles, descriptions, and canonicals before and after against a pre-migration crawl.

Doing the crawl diff catches the usual surprises: missing canonicals on paginated templates and a stray noindex carried over from staging. If you have an older site with many templates and want a second pair of hands, our team handles this kind of maintenance work regularly.