Announcement post on Times Open blog
This package is a fork of React Helmet.
<Helmet> usage is synonymous, but server and client now requires <HelmetProvider> to encapsulate state per request.
react-helmet relies on react-side-effect, which is not thread-safe. If you are doing anything asynchronous on the server, you need Helmet to encapsulate data on a per-request basis, this package does just that.
Release notes, including breaking changes, are in CHANGELOG.md.
npm install react-helmet-asyncreact 16.6 through 19 is a peer dependency.
React 19 has built-in support for hoisting <title>, <meta>, <link>, <style>, and <script> elements to <head>. Starting with version 3.0.0, this package detects the React version at runtime:
- React 19+:
<Helmet>renders actual DOM elements and lets React handle hoisting them to<head>. Mounted<Helmet>instances are merged as on React 16–18: the innermost title wins and uses the nearesttitleTemplate, and duplicate tags render once, from the innermost instance, with the exceptions noted below.<HelmetProvider>becomes a transparent passthrough. In the browser the API works as before, except thatonChangeClientStateis never called anddeferhas no effect. Server rendering needs changes:<HelmetProvider>doesn't fill incontext, so render the whole document with React instead of readinghelmetContext.helmet(see Streams);htmlAttributesandbodyAttributesare only applied in the browser; andprioritizeSeoTagsand thehelmetDataprop have no effect. - React 16–18: The existing behavior is preserved.
<Helmet>collects all instances, deduplicates tags, and applies changes to the DOM via manual manipulation (client) or serializes them for the response (server).
Note:
htmlAttributesandbodyAttributesdo not have a React 19 equivalent, so they are still applied via direct DOM manipulation on both code paths.
Note: On the server, each
<Helmet>still renders its own tags, because React renders the HTML in a single pass; they are merged after hydration. When hydrating, React reuses only the first<title>in the document, so a second server-rendered<title>stays in<head>after the merged one.
Note: React 19 loads an async
<script src>, and a<link rel="stylesheet">or<style>withprecedence, only once per URL and keeps the attributes it rendered first. When two<Helmet>s render one of these for the same URL, the outer one's attributes stay, and a<Helmet>that mounts later can't change them.
Note: With
createRoot, React leaves a<title>it did not render, such as a placeholder inindex.html, where it is. Helmet's<title>goes before it, so browsers show Helmet's title and fall back to the placeholder when no<Helmet>sets one. Remove the placeholder if you don't want two<title>elements.
If you are starting a new React 19 project and do not need htmlAttributes/bodyAttributes, titleTemplate/defaultTitle, or merging of nested <Helmet>s, you may not need this package at all — React 19's built-in metadata handling may be sufficient.
New in 1.0.0: No more default export! import { Helmet } from 'react-helmet-async'
The main way that this package differs from react-helmet is that it requires using a Provider to encapsulate Helmet state for your React tree. If you use libraries like Redux or Apollo, you are already familiar with this paradigm:
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Helmet, HelmetProvider } from 'react-helmet-async';
const app = (
<HelmetProvider>
<App>
<Helmet>
<title>Hello World</title>
<link rel="canonical" href="https://www.tacobell.com/" />
</Helmet>
<h1>Hello World</h1>
</App>
</HelmetProvider>
);
createRoot(document.getElementById('app')).render(app);On the server, we will no longer use static methods to extract state. react-side-effect
exposed a .rewind() method, which Helmet used when calling Helmet.renderStatic(). Instead, we are going
to pass a context prop to HelmetProvider, which will hold our state specific to each request.
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Helmet, HelmetProvider } from 'react-helmet-async';
const helmetContext = {};
const app = (
<HelmetProvider context={helmetContext}>
<App>
<Helmet>
<title>Hello World</title>
<link rel="canonical" href="https://www.tacobell.com/" />
</Helmet>
<h1>Hello World</h1>
</App>
</HelmetProvider>
);
const html = renderToString(app);
const { helmet } = helmetContext;
// helmet.title.toString() etc…React 19 SSR note: When using React 19,
<title>,<meta>, and<link>tags rendered inside<Helmet>are included directly in the React render output and hoisted to<head>by React itself. Thecontextobject will not be populated with helmet state on React 19. If you rely on thecontextfor server rendering, render the whole document,<html>and<head>included, with React instead of a string template; see the React 19 note under Streams.
On React 18, <Helmet> adds nothing to React's HTML output; it collects tags into helmetContext. To stream with renderToPipeableStream, write the <head> yourself in onShellReady, then call pipe(). At that point React hasn't written anything yet, and helmetContext.helmet holds the tags from every <Helmet> in the shell:
import { createServer } from 'node:http';
import { PassThrough } from 'node:stream';
import React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import { Helmet, HelmetProvider } from 'react-helmet-async';
const App = () => (
<>
<Helmet>
<title>Hello World</title>
<link rel="canonical" href="https://www.tacobell.com/" />
</Helmet>
<h1>Hello World</h1>
</>
);
// Returns the HTML that goes before and after the app
const template = (helmet) => [
`<!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
</head>
<body ${helmet.bodyAttributes.toString()}>
<div id="root">`,
`</div>
</body>
</html>`,
];
createServer((req, res) => {
const helmetContext = {};
const { pipe } = renderToPipeableStream(
<HelmetProvider context={helmetContext}>
<App />
</HelmetProvider>,
{
onShellReady() {
const [header, footer] = template(helmetContext.helmet);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.write(header);
// pipe() ends its destination, so give it a PassThrough and end res yourself
const body = new PassThrough();
body.pipe(res, { end: false });
body.on('end', () => res.end(footer));
pipe(body);
},
onShellError() {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end('<h1>Something went wrong</h1>');
},
}
);
}).listen(3000);Write base, style, script, noscript, and priority (see Prioritizing tags for SEO) into the template the same way if your <Helmet>s use them.
To hydrate the page, add your client bundle with the bootstrapScripts option, for example bootstrapScripts: ['/client.js'] (the handler above renders the app for every URL, so serve that file separately), and call hydrateRoot(document.getElementById('root'), <HelmetProvider><App /></HelmetProvider>) in it.
onShellReady doesn't wait for <Suspense> boundaries that are still loading. A <Helmet> inside one renders after the head has gone out, so its tags are missing from the HTML. <Helmet> applies them in the browser once that boundary hydrates, but crawlers that don't run JavaScript never see them. If crawlers need those tags, keep that <Helmet> outside the boundary, or write the head in onAllReady instead, which fires once every boundary has resolved. The response then doesn't start until the whole page is ready, so you may want to do that only for crawlers. With the body of onShellReady moved into a send() function:
const isCrawler = /bot|crawler|spider/i.test(req.headers['user-agent'] ?? '');
const { pipe } = renderToPipeableStream(app, {
onShellReady() {
if (!isCrawler) send();
},
onAllReady() {
// Also fires after a shell error, once onShellError has responded
if (isCrawler && !res.headersSent) send();
},
onShellError() {
// Same as above
},
});renderToNodeStream, which this section used to show, is deprecated in React 18 and removed in React 19. React 18's version waits for every Suspense boundary before it writes anything, so to keep that behavior when you switch, write the head in onAllReady instead of onShellReady.
React 19:
<HelmetProvider>doesn't fill incontexton React 19, sohelmetContext.helmetstaysundefinedand the template above doesn't apply. React 19 hoists the<title>,<meta>, and<link>tags that<Helmet>renders into<head>itself, including when streaming withrenderToPipeableStreamorrenderToReadableStream, so render the whole document,<html>included, and pipe it straight to the response. Tags inside a boundary that's still loading arrive later with that boundary's content, after</head>, so pipe inonAllReadyif crawlers need them in<head>.
While testing in using jest, if there is a need to emulate SSR, the following string is required to have the test behave the way they are expected to.
import { HelmetProvider } from 'react-helmet-async';
HelmetProvider.canUseDOM = false;This is only relevant for React 16–18. On React 19,
HelmetProvideris a passthrough andcanUseDOMhas no effect.
On the client, <Helmet> applies its changes in the next animation frame, so when several <Helmet> instances change in one render, the document is updated once with the latest state. Browsers pause animation frames in background tabs, so if no frame comes within 100 ms the changes are applied anyway. A title set while the tab is in the background, such as an unread count, shows up after about a second, since browsers also throttle timers in background tabs.
Set defer={false} to apply changes synchronously instead. As with other props, the value on the innermost <Helmet> applies:
<Helmet defer={false}>
<title>Hello World</title>
</Helmet>React 19:
deferhas no effect on React 19. React renders the tags itself, andhtmlAttributesandbodyAttributesare applied as soon as<Helmet>mounts or updates.
It is understood that in some cases for SEO, certain tags should appear earlier in the HEAD. Using the prioritizeSeoTags flag on any <Helmet> component allows the server render of react-helmet-async to expose a method for prioritizing relevant SEO tags.
In the component:
<Helmet prioritizeSeoTags>
<title>A fancy webpage</title>
<link rel="notImportant" href="https://www.chipotle.com" />
<meta name="whatever" value="notImportant" />
<link rel="canonical" href="https://www.tacobell.com" />
<meta property="og:title" content="A very important title" />
<meta charSet="utf-8" />
</Helmet>In your server template:
<html>
<head>
${helmet.title.toString()}
${helmet.priority.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
${helmet.script.toString()}
</head>
...
</html>Will result in:
<html>
<head>
<title>A fancy webpage</title>
<meta charset="utf-8" />
<meta property="og:title" content="A very important title" />
<link rel="canonical" href="https://www.tacobell.com" />
<meta name="whatever" value="notImportant" />
<link rel="notImportant" href="https://www.chipotle.com" />
</head>
...
</html><meta charset> is always the first prioritized tag, because browsers only honor it within the first 1024 bytes of the document.
A list of prioritized tags and attributes can be found in constants.ts.
React 19: The
prioritizeSeoTagsflag has no effect on React 19, since tags are rendered as regular JSX elements and their order in<head>is determined by React's rendering order.
You can optionally use <Helmet> outside a context by manually creating a stateful HelmetData instance, and passing that stateful object to each <Helmet> instance:
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Helmet, HelmetData } from 'react-helmet-async';
const helmetData = new HelmetData({});
const app = (
<App>
<Helmet helmetData={helmetData}>
<title>Hello World</title>
<link rel="canonical" href="https://www.tacobell.com/" />
</Helmet>
<h1>Hello World</h1>
</App>
);
const html = renderToString(app);
const { helmet } = helmetData.context;React 19: The
helmetDataprop is ignored on React 19, since<Helmet>renders elements directly without the need for external state management.
| React Version | Behavior |
|---|---|
| 16.6+ | Full support via HelmetProvider context and manual DOM updates |
| 17.x | Full support via HelmetProvider context and manual DOM updates |
| 18.x | Full support via HelmetProvider context and manual DOM updates |
| 19.x+ | Renders native JSX elements; React handles <head> hoisting |
Use the Node.js version in .nvmrc (CI also runs 22 and 26) and the pnpm version in packageManager. Linting and formatting use oxlint and oxfmt with @wonderboymusic/oxc-config.
pnpm install
pnpm lint # oxlint + oxfmt --check
pnpm lint-fix # apply lint fixes and formatting
pnpm test # unit tests
pnpm run test:e2e # server + browser e2e tests
pnpm run test:all # everythingCI runs the type check, lint and all tests on every push, and on pull requests from forks.
Add each user-facing change to the ## [Unreleased] section of CHANGELOG.md in the pull request that makes it. To release, run this on an up-to-date main:
pnpm release minor # or major, patch, or an exact version such as 3.1.0It moves the unreleased entries into a section for the new version, bumps package.json, and opens a release/v<version> pull request. Merging that pull request runs the Publish workflow, which publishes the package to npm with provenance and creates the v<version> GitHub release and tag, with the changelog section as the release notes. pnpm release minor --dry-run prints the next version and its notes without changing anything.
The workflow authenticates to npm through trusted publishing, so there is no npm token to manage. On npmjs.com, the package's trusted publisher has to be GitHub Actions with user staylor, repository react-helmet-async and workflow publish.yml, with npm publish allowed.
Licensed under the Apache 2.0 License, Copyright © 2018 Scott Taylor