Open-source project
staylor/react-helmet-async avatar
staylor/react-helmet-async

react-helmet-async: per-request document head tags for React

Thread-safe Helmet for React 16+ and friends

2,298 stars175 forksTypeScriptApache-2.0

At a glance

What is it?
A fork of react-helmet that moves head tag state into a provider so concurrent server renders do not collide. Version 3 delegates to React 19 own hoisting, which makes the library optional on the newest React.
Who is it for?
Adopt it if you are on React 16 through 18 and render on the server with anything asynchronous in the tree, since that is exactly the collision it was forked to fix. Do not adopt it for a new React 19 app that only needs title, meta and link tags, because the README says plainly that React handles those natively.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Activity is slowing. The repository last received commits 7 months ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 28, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Why the original react-helmet needed forking

react-helmet-async is a fork of nfl/react-helmet, and the reason for the fork is stated in one sentence near the top of the README: react-helmet relies on react-side-effect, which is not thread-safe, so if you do anything asynchronous on the server you need head tag data encapsulated per request.

The difference this makes in your component tree is a wrapper component. react-helmet exposed static methods for pulling state back out of a render, and this package replaces that with a context object you pass in yourself. The README compares this to Redux or Apollo, which is a fair analogy because the shape of the fix is the same: state that used to live in a module singleton now lives in a provider that you mount once per request.

javascript
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);

The import shape is worth noting because it changed. The README flags it as new in 1.0.0: there is no default export, so the named import is the only supported form. A codebase migrating from react-helmet will need every import line updated, not just the one at the entry point.

Reading state back on the server through a context object

On the server the library does not hand you a function to call. You create a plain object, pass it to the provider as a context prop, and read it after the render completes.

javascript
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>
      </Helmet>
    </App>
  </HelmetProvider>
);

const html = renderToString(app);

After that call the object carries a helmet key with the collected tags, and the README refers to reading title and the rest off it with a toString call. Because the object is created per request, two concurrent renders cannot see each other state. That is the entire mechanism, and it is why the fork exists.

Streaming needs one extra constraint. The README states the package only works with streaming if your head data is output outside of renderToNodeStream, which means your template has to be assembled after you have already resolved your data. The example does that with a getDataFromTree call, a template function that splits the document into a header and a footer, and a through stream that writes the header, pipes the render, then appends the footer at the end. If you stream and your data resolution happens inside the stream, there is no point at which the context object is complete, and this library will not give you tags you can hoist.

React 19 turns the provider into a passthrough

Version 3.0.0 adds runtime detection of the React version and branches behavior on it. This is the most consequential thing in the repository and it is easy to miss because the API stays identical.

On React 19 and later, Helmet renders real DOM elements and lets React hoist title, meta, link, style and script into head itself. HelmetProvider becomes a transparent passthrough. Every code sample in the README still runs unchanged, which is the point of the design, but the library is no longer doing the hoisting on that path. On React 16 through 18 the old behavior is preserved: Helmet collects instances, deduplicates tags, and applies them through direct DOM manipulation on the client or serializes them for the response on the server.

Two consequences follow, and both are stated in the README rather than inferred. First, the server context object is not populated with helmet state under React 19, so code that reads helmetContext.helmet after renderToString gets nothing on that version. Second, htmlAttributes and bodyAttributes have no React 19 equivalent, so those two are still applied by direct DOM manipulation on both paths. That last detail means a page relying on lang attributes on the html element is using the library's own code even on the newest React.

The README also names the feature set you give up by dropping the package on React 19: html and body attributes, context serialization for SSR, onChangeClientState, prioritizeSeoTags, and titleTemplate. If any of those is load-bearing, the library is still doing work for you under React 19 even though React is handling the ordinary tags.

prioritizeSeoTags and the order of your head template

Tag order in head is not a documented requirement of any search engine, but this library treats it as a feature anyway. Adding the prioritizeSeoTags flag to a Helmet instance makes the server render expose a separate priority collection, and your template chooses where it goes.

html
<html>
  <head>
    ${helmet.title.toString()}
    ${helmet.priority.toString()}
    ${helmet.meta.toString()}
    ${helmet.link.toString()}
    ${helmet.script.toString()}
  </head>

In the example Helmet instance, a title and an og:title meta go into the prioritized set while two deliberately unimportant tags are left out of it. The README then shows the resulting head with the title first, the og:title, the canonical link, then the two unimportant tags in their original relative order. The list of which tags and attributes qualify lives in src/constants.ts according to the README, which is one of the few pointers this repository gives to its own source.

The flag has no effect on React 19, where React 19 itself decides hoisting order. If you are pinning the layout of your head, that pinning is a React 16 through 18 concern.

There is also a testing detail that costs people an afternoon. When emulating SSR under Jest you have to set a static property on the provider before the test runs, and the README gives it as a standalone snippet. On React 19 the property has no effect, again because the provider is a passthrough.

The manifest says 3.0.0 and the releases page stops at 0.2.0

Here is a contradiction worth knowing about before you plan an upgrade. The package manifest on the default branch carries version 3.0.0 and a description of thread-safe Helmet for React 16 to 18 with native support for React 19 and later. The releases list on the platform holds two entries, v0.1.0 named Initial open source release from 2018-05-02 and v0.2.0 named for Fragment support and fixes from 2018-10-08. Nothing between 0.2.0 and 3.0.0 was ever cut as a platform release.

Both facts are true and neither is a mistake you need to work around. The tag list is an artifact of the project changing how it ships, and the manifest on the branch reflects the version that npm carries today. The practical rule is that you cannot use this repository's releases page to answer what changed in the version you have installed. Read the installed package metadata, or the changelog the documentation site publishes, not the platform tag list.

The dependency picture is small and worth a look for anyone worried about supply chain surface. Three runtime dependencies: invariant, react-fast-compare, and shallowequal. React itself is a peer dependency spanning ^16.6.0 through ^19.0.0. Everything else, including the build chain of typescript, vite, esbuild and vitest, sits in devDependencies, and the published payload is narrowed by a files entry that limits it to the lib directory.

That dev chain is where the second contradiction sits. The manifest pins react at 18.2.0 and @types/react at 18.2.39, typescript at 5.2.2, vite at 4.5.0 and vitest at 0.34.6, while carrying @playwright/test at ^1.58.2 for the end to end suite. A test run from this manifest exercises React 18, not React 19, even though the peer range and the README both promise React 19 behavior. If you depend on the React 19 branch, verify it in your own project rather than assuming the repository suite covers it. The last push on the default branch was 2026-03-03.

Where this library is the wrong choice

The clearest wrong choice is a greenfield React 19 application that only needs a title, a few meta tags and a canonical link. React 19 hoists those elements natively, the README says so, and every feature this library adds on top of that path is either inert or, for html and body attributes, a small amount of manual DOM work you could write yourself.

The second wrong choice is a project that never renders on the server. The provider exists for per-request isolation. On the client there is one tree, so a module level singleton does not collide with anything, which is the situation react-helmet handled adequately before the fork.

The third is a project that has already committed to a different answer. React 19 own metadata support is that answer for the common case, and for the rest of the ecosystem there are document metadata libraries built on the newer API rather than on manual DOM writes. Comparing approaches here matters more than comparing package names: this library collects, deduplicates and then applies tags itself on React 16 through 18, while the newer native path hands elements to the framework and lets it place them. Those produce different debugging experiences, because in the first case a tag that does not appear in the head is usually a collection problem, and in the second it is usually a render problem.

Editorial conclusion

Adopt it if you are on React 16 through 18 and render on the server with anything asynchronous in the tree, since that is exactly the collision it was forked to fix. Do not adopt it for a new React 19 app that only needs title, meta and link tags, because the README says plainly that React handles those natively. Verify one thing first in your own template: if you rely on the context object to read helmet state after renderToString, that path is documented as unpopulated on React 19, so a HelmetProvider may now be decoration around state you never receive.

Frequently asked questions

What is a helmet in React JS?

A helmet library is the naming convention for packages that manage what goes in the document head from inside a React tree, most importantly the title, meta description, canonical link and Open Graph tags. react-helmet-async is the fork of react-helmet that keeps that head tag state in a HelmetProvider so it can be isolated per server request.

Do I still need react-helmet-async with React 19?

Not for title, meta, link, style and script tags, because React 19 hoists those to head itself and the README describes HelmetProvider as a transparent passthrough on that version. You still need it for htmlAttributes and bodyAttributes, which have no React 19 equivalent, and for context serialization, titleTemplate, onChangeClientState and prioritizeSeoTags.

How do I get head tag state out of a server render?

Create an empty object per request, pass it to HelmetProvider as its context prop, and read the helmet property after renderToString returns. The README notes that this object is not populated on React 19, where the tags are part of the render output instead. With streaming, your head data has to be written outside of renderToNodeStream.

Official sources

  1. Issues
  2. License: Apache-2.0
  3. README
  4. Releases
  5. staylor/react-helmet-async on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/staylor-react-helmet-async.svg)](https://hysenlabs.com/projects/staylor-react-helmet-async)