Skip to content

About

Thread-safe Helmet for React 16+ and friends

Topics

Resources

Stars

2.3k stars

Watchers

13 watching

Forks

Repository files navigation

react-helmet-async

CI

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.

Installation

npm install react-helmet-async

react 16.6 through 19 is a peer dependency.

React 19

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 nearest titleTemplate, 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 that onChangeClientState is never called and defer has no effect. Server rendering needs changes: <HelmetProvider> doesn't fill in context, so render the whole document with React instead of reading helmetContext.helmet (see Streams); htmlAttributes and bodyAttributes are only applied in the browser; and prioritizeSeoTags and the helmetData prop 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: htmlAttributes and bodyAttributes do 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> with precedence, 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 in index.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.

Usage

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. The context object will not be populated with helmet state on React 19. If you rely on the context for server rendering, render the whole document, <html> and <head> included, with React instead of a string template; see the React 19 note under Streams.

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 in context on React 19, so helmetContext.helmet stays undefined and the template above doesn't apply. React 19 hoists the <title>, <meta>, and <link> tags that <Helmet> renders into <head> itself, including when streaming with renderToPipeableStream or renderToReadableStream, 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 in onAllReady if crawlers need them in <head>.

Usage in Jest

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, HelmetProvider is a passthrough and canUseDOM has no effect.

Deferred updates

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: defer has no effect on React 19. React renders the tags itself, and htmlAttributes and bodyAttributes are applied as soon as <Helmet> mounts or updates.

Prioritizing tags for SEO

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 prioritizeSeoTags flag 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.

Usage without Context

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 helmetData prop is ignored on React 19, since <Helmet> renders elements directly without the need for external state management.

Compatibility

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

Development

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    # everything

CI runs the type check, lint and all tests on every push, and on pull requests from forks.

Releasing

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.0

It 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.

License

Licensed under the Apache 2.0 License, Copyright © 2018 Scott Taylor

About

Thread-safe Helmet for React 16+ and friends

Topics

Resources

Stars

2.3k stars

Watchers

13 watching

Forks

Releases

Packages

Used by

Contributors

Languages