Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 0 additions & 31 deletions .circleci/config.yml

This file was deleted.

61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
push:

jobs:
build:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [20, 22, 24]

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: latest

- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Type checks
run: pnpm tsc

- name: Lint
run: pnpm lint

- name: Unit tests
run: pnpm test

- name: SSR E2E tests
run: pnpm run test:e2e:server

e2e-browser:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: latest

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium

- name: Browser E2E tests
run: pnpm run test:e2e:browser
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
lib
node_modules
package-lock.json
test-results
playwright-report
2 changes: 1 addition & 1 deletion .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -1 +1 @@
yarn commitlint --edit $1
pnpm commitlint --edit $1
52 changes: 44 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# react-helmet-async

[![CircleCI](https://circleci.com/gh/staylor/react-helmet-async.svg?style=svg)](https://circleci.com/gh/staylor/react-helmet-async)
[![CI](https://github.com/staylor/react-helmet-async/actions/workflows/ci.yml/badge.svg)](https://github.com/staylor/react-helmet-async/actions/workflows/ci.yml)

[Announcement post on Times Open blog](https://open.nytimes.com/the-future-of-meta-tag-management-for-modern-react-development-ec26a7dc9183)

Expand All @@ -9,15 +9,26 @@ This package is a fork of [React Helmet](https://github.com/nfl/react-helmet).

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

## 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>`. `<HelmetProvider>` becomes a transparent passthrough. The existing API is fully compatible — you do not need to change any code.
- **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.

If you are starting a new React 19 project and do not need `htmlAttributes`/`bodyAttributes`, SSR `context` serialization, `onChangeClientState`, `prioritizeSeoTags`, or `titleTemplate` support, you may not need this package at all — React 19's built-in metadata handling may be sufficient.

## Usage

**New is 1.0.0:** No more default export! `import { Helmet } from 'react-helmet-async'`
**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:

```javascript
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import { Helmet, HelmetProvider } from 'react-helmet-async';

const app = (
Expand All @@ -32,10 +43,7 @@ const app = (
</HelmetProvider>
);

ReactDOM.hydrate(
app,
document.getElementById(‘app’)
);
createRoot(document.getElementById('app')).render(app);
```

On the server, we will no longer use static methods to extract state. `react-side-effect`
Expand Down Expand Up @@ -68,6 +76,8 @@ 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, you can render these tags directly in your component tree instead and let React 19 handle them natively.

## Streams

This package only works with streaming if your `<head>` data is output outside of `renderToNodeStream()`.
Expand Down Expand Up @@ -117,6 +127,8 @@ renderToNodeStream(app)
.pipe(res);
```

> **React 19:** React 19's `renderToReadableStream` natively handles `<title>`, `<meta>`, and `<link>` hoisting during streaming, so the manual context extraction shown above is not necessary.

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

Expand All @@ -126,6 +138,8 @@ 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.

## 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.
Expand Down Expand Up @@ -173,14 +187,16 @@ Will result in:

A list of prioritized tags and attributes can be found in [constants.ts](./src/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:


```js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Helmet, HelmetProvider, HelmetData } from 'react-helmet-async';
import { Helmet, HelmetData } from 'react-helmet-async';

const helmetData = new HelmetData({});

Expand All @@ -199,6 +215,26 @@ 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

```bash
pnpm install
pnpm test # unit tests
pnpm run test:e2e # server + browser e2e tests
pnpm run test:all # everything
```

## License

Licensed under the Apache 2.0 License, Copyright © 2018 Scott Taylor
65 changes: 65 additions & 0 deletions __tests__/react19/base.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from 'react';
import { vi } from 'vitest';

import { Helmet } from '../../src';

import { render, unmount, getMountElement } from './utils';

vi.mock('../../src/reactVersion', () => ({ isReact19: true }));

afterEach(() => {
unmount();
});

describe('React 19 – base tag', () => {
describe('API', () => {
it('renders a base tag', () => {
render(<Helmet base={{ href: 'http://mysite.com/' }} />);

const base = getMountElement().querySelector('base');
expect(base).not.toBeNull();
expect(base).toHaveAttribute('href', 'http://mysite.com/');
});

it('renders base tag with target', () => {
render(<Helmet base={{ href: 'http://mysite.com/', target: '_blank' }} />);

const base = getMountElement().querySelector('base');
expect(base).toHaveAttribute('href', 'http://mysite.com/');
expect(base).toHaveAttribute('target', '_blank');
});

it('does not render a base tag when none specified', () => {
render(<Helmet />);

const base = getMountElement().querySelector('base');
expect(base).toBeNull();
});

it('updates base tag on re-render', () => {
render(<Helmet base={{ href: 'http://first.com/' }} />);

let base = getMountElement().querySelector('base');
expect(base).toHaveAttribute('href', 'http://first.com/');

render(<Helmet base={{ href: 'http://second.com/' }} />);

base = getMountElement().querySelector('base');
expect(base).toHaveAttribute('href', 'http://second.com/');
});
});

describe('Declarative API', () => {
it('renders a base tag from children', () => {
render(
<Helmet>
<base href="http://mysite.com/" />
</Helmet>
);

const base = getMountElement().querySelector('base');
expect(base).not.toBeNull();
expect(base).toHaveAttribute('href', 'http://mysite.com/');
});
});
});
106 changes: 106 additions & 0 deletions __tests__/react19/bodyAttributes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React from 'react';
import { vi } from 'vitest';

import { Helmet } from '../../src';

import { render, unmount } from './utils';

vi.mock('../../src/reactVersion', () => ({ isReact19: true }));

afterEach(() => {
unmount();
});

describe('React 19 – body attributes', () => {
describe('API', () => {
it('applies body attributes to <body> via DOM manipulation', () => {
render(
<Helmet
bodyAttributes={{
className: 'test-class',
id: 'body-id',
}}
/>
);

const bodyTag = document.body;
expect(bodyTag).toHaveAttribute('class', 'test-class');
expect(bodyTag).toHaveAttribute('id', 'body-id');
expect(bodyTag).toHaveAttribute('data-rh-managed', 'class,id');
});

it('updates body attributes on re-render', () => {
render(<Helmet bodyAttributes={{ className: 'first' }} />);

expect(document.body).toHaveAttribute('class', 'first');

render(<Helmet bodyAttributes={{ className: 'second' }} />);

expect(document.body).toHaveAttribute('class', 'second');
});

it('removes body attributes that are no longer present', () => {
render(<Helmet bodyAttributes={{ className: 'test', id: 'myid' }} />);

expect(document.body).toHaveAttribute('class', 'test');
expect(document.body).toHaveAttribute('id', 'myid');

render(<Helmet bodyAttributes={{ className: 'test' }} />);

expect(document.body).toHaveAttribute('class', 'test');
expect(document.body).not.toHaveAttribute('id');
});

it('cleans up body attributes on unmount', () => {
render(<Helmet bodyAttributes={{ className: 'test' }} />);

expect(document.body).toHaveAttribute('class', 'test');

unmount();

expect(document.body).not.toHaveAttribute('class');
expect(document.body).not.toHaveAttribute('data-rh-managed');
});

it('inner instance overrides outer instance for conflicting body attributes', () => {
render(
<>
<Helmet bodyAttributes={{ className: 'outer' }} />
<Helmet bodyAttributes={{ className: 'inner' }} />
</>
);

expect(document.body).toHaveAttribute('class', 'inner');
});

it('restores outer instance body attributes when inner instance unmounts', () => {
render(
<>
<Helmet bodyAttributes={{ className: 'outer' }} />
<Helmet bodyAttributes={{ className: 'inner' }} />
</>
);

expect(document.body).toHaveAttribute('class', 'inner');

// Simulate unmounting the inner instance by re-rendering with only the outer
render(<Helmet bodyAttributes={{ className: 'outer' }} />);

expect(document.body).toHaveAttribute('class', 'outer');
});
});

describe('Declarative API', () => {
it('applies body attributes from <body> child', () => {
render(
<Helmet>
<body className="test-class" id="body-id" />
</Helmet>
);

const bodyTag = document.body;
expect(bodyTag).toHaveAttribute('class', 'test-class');
expect(bodyTag).toHaveAttribute('id', 'body-id');
});
});
});
Loading
Loading