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, andnoscriptelements. - You cannot set attributes on
<html>or<body>from Head. UseonRenderBodywithsetHtmlAttributesingatsby-ssr.jsforlang. - 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
- Inventory Helmet usage.
- Create the shared SEO component.
- Export Head on every page and template.
- Move
langtogatsby-ssr.js. - Uninstall Helmet and its plugin.
- 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.