react-helmet
react-helmet is a React component for changing document metadata from inside the component tree. Render ordinary title, meta, link, script, style, html, or body elements inside Helmet, and it updates the browser head after rendering. Nested Helmet instances are reduced so the deepest title or matching metadata wins, while same-component duplicates can remain. It also exposes renderStatic for extracting head markup after legacy server rendering. The idea is simple, but this package predates concurrent React and modern framework metadata APIs.
Keep it when a stable legacy SPA already depends on its component API. Do not choose it for new React or concurrent SSR work; react-helmet-async is the least disruptive replacement, and a framework-native metadata API is better when available.
Use it if
- You are maintaining an existing React 16 to 18 single-page app that already uses Helmet successfully
- You need nested route components to override a parent title, description, canonical link, or social metadata
- Your legacy server renderer can call Helmet.renderStatic immediately after every render and processes requests without shared Helmet state
- You need one component to manage title, meta, link, script, style, html attributes, and body attributes
- You are starting a new React app: version 6.1.0 was released in June 2020, the last repository push was in July 2023, and current alternatives support modern React
- You use React 19: the package's react-side-effect dependency declares peer support only through React 18, and an open repository issue documents the resulting React 19 peer conflict
- You render concurrent server requests: react-helmet tracks mounted instances globally, while react-helmet-async uses a per-request context specifically to avoid cross-request state
- You can use your framework's metadata API: Next.js and other SSR frameworks integrate head output with streaming, routing, and server components instead of requiring a post-render global extraction step
- You need first-party TypeScript declarations: react-helmet 6.1.0 has no types field and requires the separately maintained @types/react-helmet package
Setup reality
Install react-helmet beside React; its declared React peer range starts at 16.3, and the package brings object-assign, prop-types, react-fast-compare, and react-side-effect. It ships CommonJS and an ES module build but no TypeScript declarations, so TypeScript projects normally add @types/react-helmet. Client-only use needs no provider: render Helmet anywhere under React and later or deeper instances override matching values. Server rendering is where the contract becomes fragile. You must render the app first, then call Helmet.renderStatic() exactly once for that render and place each returned title, meta, link, style, script, noscript, htmlAttributes, and bodyAttributes value into the HTML template. The README explicitly warns that failing to call renderStatic leaks the accumulated mounted-instance state. That state is process-global rather than request-scoped, which is a poor fit for concurrent SSR and streaming. Bundlers used for both client and server must also resolve one shared react-helmet instance; the README suggests making it a webpack external, because two copies split the tracked state. DOM updates defer through requestAnimationFrame by default, so tests or background-tab behavior may need defer={false}. On React 19, npm can report a peer conflict through react-side-effect even though react-helmet itself declares react >=16.3. Inline script and style content must be passed as strings, and adding JSON-LD or third-party scripts does not make that content safe under your Content Security Policy. This is maintenance territory, not a comfortable new dependency.
Patterns
Set a page titleset-page-title
import { Helmet } from 'react-helmet';
export function ProductPage({ product }) {
return (
<>
<Helmet>
<title>{product.name}</title>
</Helmet>
<h1>{product.name}</h1>
</>
);
}A deeper or later mounted Helmet can override the title. Keep user-controlled title values as React text rather than injecting HTML.
Set description and robots metadataset-description
<Helmet>
<meta name="description" content={summary} />
<meta name="robots" content={isPublic ? 'index,follow' : 'noindex,nofollow'} />
</Helmet>Matching meta tags from nested Helmet instances are replaced. Verify the final server HTML because client-only metadata arrives too late for some crawlers.
Apply a site-wide title templateuse-title-template
// Layout.jsx
<Helmet defaultTitle="Acme" titleTemplate="%s | Acme" />
// Route component
<Helmet>
<title>Pricing</title>
</Helmet>defaultTitle is used when no nested title exists. The %s placeholder in titleTemplate receives the child title.
Add a canonical linkset-canonical-url
<Helmet>
<link rel="canonical" href={`https://example.com${canonicalPath}`} />
</Helmet>Build an absolute, normalized URL yourself. Helmet inserts the tag but does not validate hosts, remove tracking parameters, or decide canonical policy.
Add Open Graph and Twitter card tagsset-social-cards
<Helmet>
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={absoluteImageUrl} />
<meta name="twitter:card" content="summary_large_image" />
</Helmet>Use absolute image URLs. Social crawlers usually read the initial HTML, so render these tags on the server rather than relying on a client update.
Update html and body attributesset-html-language
<Helmet>
<html lang={locale} dir={isRtl ? 'rtl' : 'ltr'} />
<body className="checkout-page" />
</Helmet>On the server, htmlAttributes and bodyAttributes must be emitted separately from head tags. Client changes can conflict with code that also owns body classes.
Add structured data as JSON-LDadd-json-ld
const structuredData = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
};
<Helmet>
<script type="application/ld+json">
{JSON.stringify(structuredData).replace(/</g, '\u003c')}
</script>
</Helmet>Escape less-than characters when serializing data that could contain user input so a closing script sequence cannot break out of the element.
Emit alternate-language linksadd-alternate-locales
<Helmet>
{locales.map(({ code, url }) => (
<link key={code} rel="alternate" hrefLang={code} href={url} />
))}
</Helmet>Stable React keys are still required. Multiple links declared in the same Helmet instance are preserved rather than collapsed.
Extract head markup after legacy SSRrender-on-server
import { renderToString } from 'react-dom/server';
import { Helmet } from 'react-helmet';
const appHtml = renderToString(<App url={request.url} />);
const head = Helmet.renderStatic();
const html = `<html ${head.htmlAttributes.toString()}>
<head>${head.title.toString()}${head.meta.toString()}${head.link.toString()}</head>
<body ${head.bodyAttributes.toString()}><div id="root">${appHtml}</div></body></html>`;Call renderStatic after every server render. The README warns that omitting it leaks mounted-instance state; the global model is not suitable for concurrent streaming SSR.
Render extracted tags as React componentsrender-server-components
const head = Helmet.renderStatic();
function Document() {
return (
<html {...head.htmlAttributes.toComponent()}>
<head>
{head.title.toComponent()}
{head.meta.toComponent()}
{head.link.toComponent()}
</head>
<body {...head.bodyAttributes.toComponent()} />
</html>
);
}Use the same extracted head object for the whole document. This pattern is for a custom legacy renderer, not React Server Components.
Apply client head changes immediatelyupdate-without-deferral
<Helmet defer={false}>
<title>Upload complete</title>
</Helmet>The default defers DOM work with requestAnimationFrame. Turning it off can help tests and background tabs but makes updates synchronous.
Observe client-side head updatesobserve-dom-changes
<Helmet
onChangeClientState={(state, addedTags, removedTags) => {
console.log(state.title, addedTags, removedTags);
}}
>
<title>{title}</title>
</Helmet>The callback runs for client DOM changes, not as an analytics guarantee. Avoid setting state here in a way that creates a render loop.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-helmet-async | npm | Use it for a close Helmet-compatible API with per-request SSR context and declared React 19 support |
| @unhead/react | npm | Use it in a modern React 19 application that wants typed head management, plugins, and server rendering |
| next | npm | Use its built-in metadata system when the application is already on Next.js and needs streaming or server-component integration |