Skip to main content
Version: 16.x

React 19 Native Metadata: Replacing react-helmet and react_component_hash

React 19 introduces built-in support for rendering <title>, <meta>, and <link> tags anywhere in your component tree. React automatically hoists them into the document <head>. This eliminates the need for react-helmet and, for metadata use cases, react_component_hash.

Why Migrate?​

react-helmet + react_component_hashReact 19 Native Metadata
SSR approachrenderToString onlyWorks with renderToString¹, streaming, and RSC
Streaming supportNot compatibleFully compatible
Dependenciesreact-helmet packageNone (built into React 19)
Server setupRender-function returning object + Helmet.renderStatic()Standard component
View helperreact_component_hash (returns Hash)react_component or stream_react_component (returns HTML)
Bundle complexitySeparate server/client render-functionsSame component for both

¹ With renderToString, metadata tags initially appear in <body> (since React on Rails renders component fragments, not full documents). They are hoisted to <head> only after client hydration. Streaming and RSC do not have this limitation.

What React 19 Hoists to <head>​

React 19 automatically hoists these elements from anywhere in the component tree into the document <head>:

ElementHoisted?Notes
<title>YesLast rendered <title> wins
<meta>YesAll variants (name, property, httpEquiv, charSet)
<link rel="stylesheet">YesMust include precedence prop for ordering
<link rel="preload">Yes
<link rel="icon">YesAnd other rel types
<script async src="...">YesOnly async scripts with src
<style> with precedenceYesInline styles with precedence prop
<script> (inline)NoStays where rendered in the tree
<script defer>NoNot hoisted

Key limitation: Inline <script> tags (including those with dangerouslySetInnerHTML) are not hoisted to <head>. They render where placed in the component tree. This matters for use cases like Apollo Client state serialization — see What react_component_hash Is Still Needed For.

Migration Guide​

Step 1: Remove react-helmet​

Uninstall the package:

yarn remove react-helmet
# or: npm uninstall react-helmet
# or: pnpm remove react-helmet

Step 2: Replace Helmet Tags with Native Tags​

Before (react-helmet):

import { Helmet } from 'react-helmet';

const MyPage = ({ title, description }) => (
<div>
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href="https://example.com/page" />
</Helmet>
<h1>{title}</h1>
<p>Page content...</p>
</div>
);

After (React 19 native):

const MyPage = ({ title, description }) => (
<div>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href="https://example.com/page" />
<h1>{title}</h1>
<p>Page content...</p>
</div>
);

The metadata tags can be placed anywhere in the component tree — React 19 hoists them to <head> automatically. There is no need for a wrapper component.

Step 3: Replace the Render-Function and View Helper​

This is the key architectural change. With react-helmet, you needed a render-function returning an object and react_component_hash in your view. With React 19 native metadata, you use a standard component and react_component or stream_react_component.

Before — Server render-function (react-helmet):

// MyPageServerApp.server.jsx
import { renderToString } from 'react-dom/server';
import { Helmet } from 'react-helmet';
import MyPage from './MyPage';

export default (props, _railsContext) => {
const componentHtml = renderToString(<MyPage {...props} />);
const helmet = Helmet.renderStatic();

return {
renderedHtml: {
componentHtml,
title: helmet.title.toString(),
meta: helmet.meta.toString(),
link: helmet.link.toString(),
},
};
};

Before — Client component (react-helmet):

// MyPageClientApp.jsx
import MyPage from './MyPage';

export default (props) => () => <MyPage {...props} />;

Before — ERB view (react-helmet):

<% page_data = react_component_hash("MyPageApp",
props: { title: "My Page", description: "..." },
trace: true) %>

<% content_for :title do %>
<%= page_data['title'] %>
<% end %>
<% content_for :head do %>
<%= page_data['meta'] %>
<%= page_data['link'] %>
<% end %>

<%= page_data["componentHtml"] %>

After — Single component (React 19 native):

// MyPageApp.jsx
const MyPageApp = ({ title, description }) => (
<div>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href="https://example.com/page" />
<h1>{title}</h1>
<p>Page content...</p>
</div>
);

export default MyPageApp;

After — ERB view (React 19 native, without streaming):

<%= react_component("MyPageApp",
props: { title: "My Page", description: "..." },
prerender: true) %>

After — ERB view (React 19 native, with streaming):

<%= stream_react_component("MyPageApp",
props: { title: "My Page", description: "..." },
prerender: true) %>

No content_for, no separate server/client files, no render-function. React 19 handles the metadata hoisting automatically during both renderToString and renderToPipeableStream.

Step 4: Remove Unused content_for Blocks​

If your layout has content_for blocks that were only used for react-helmet output, you can remove them:

<!-- Before: needed for react-helmet output -->
<head>
<%= yield(:title) if content_for?(:title) %>
<%= yield(:head) if content_for?(:head) %>
</head>

<!-- After: React 19 hoists metadata directly to <head> -->
<head>
<!-- React 19 automatically inserts <title>, <meta>, <link> here -->
</head>

Note: Keep content_for blocks if other (non-React) parts of your app still use them.

Streaming with Native Metadata​

One of the biggest advantages of React 19 native metadata over react-helmet is streaming compatibility. With stream_react_component, metadata tags are included in the initial HTML shell and hoisted to <head> before the browser sees the content.

Async Components with Dynamic Metadata​

Metadata can be rendered inside async components within Suspense boundaries. When the async component resolves, React streams the metadata to the client and updates <head>:

const UserProfile = async ({ userId }) => {
const user = await fetchUser(userId);

return (
<>
<title>{`${user.name}'s Profile | My App`}</title>
<meta name="description" content={`Profile page for ${user.name}`} />
<h1>{user.name}</h1>
<p>{user.bio}</p>
</>
);
};

const ProfilePage = ({ userId }) => (
<div>
{/* Initial metadata shown while loading */}
<title>Loading Profile... | My App</title>
<meta property="og:site_name" content="My App" />

<Suspense fallback={<ProfileSkeleton />}>
{/* Updated metadata streamed when resolved */}
<UserProfile userId={userId} />
</Suspense>
</div>
);

The initial <title> ("Loading Profile...") appears immediately. When UserProfile resolves, React replaces it with the user-specific title.

React Server Components (RSC) with Native Metadata​

Native metadata works in React Server Components too. Since RSC components run exclusively on the server, metadata tags are always server-rendered — ideal for SEO:

// NativeMetadataRSCApp.jsx (no 'use client' directive — this is a Server Component)
import React, { Suspense } from 'react';

const AsyncContent = async ({ slug }) => {
const article = await fetchArticle(slug);

return (
<>
<title>{article.title}</title>
<meta name="description" content={article.excerpt} />
<meta property="og:title" content={article.title} />
<meta property="og:image" content={article.coverImage} />
<article>{article.body}</article>
</>
);
};

const ArticlePage = ({ slug }) => (
<div>
<title>Loading...</title>
<link rel="canonical" href={`https://example.com/articles/${slug}`} />
<Suspense fallback={<ArticleSkeleton />}>
<AsyncContent slug={slug} />
</Suspense>
</div>
);

export default ArticlePage;

Hybrid Approach: Rails-Side + React-Side Metadata​

For pages where some metadata is known at the Rails level (and doesn't need React), you can combine Rails-side metadata with React 19 native metadata for dynamic content:

<%# Static metadata set in Rails — no React needed %>
<% content_for :title, "My App — Dashboard" %>
<% content_for :head do %>
<meta property="og:site_name" content="My App" />
<link rel="canonical" href="<%= dashboard_url %>" />
<% end %>

<%# Dynamic content rendered by React — component handles its own metadata %>
<%= stream_react_component("DashboardApp",
props: { user: @user },
prerender: true) %>

This approach is useful when the page title and Open Graph tags are static, but the component needs to render additional metadata based on its internal state.

What react_component_hash Is Still Needed For​

React 19 native metadata replaces react-helmet for <title>, <meta>, and <link> tags. However, react_component_hash is still needed for use cases where the render-function returns non-metadata HTML that must be placed outside the component's DOM node:

Apollo Client State Serialization​

Apollo Client's SSR pattern requires extracting the cache state after rendering the entire component tree, then serializing it as a <script> tag in the page. This cannot be done with native metadata because:

  1. client.extract() requires all queries to resolve first (full tree convergence)
  2. Inline <script> tags are not hoisted by React 19
  3. The state must be available before hydration begins
// This pattern still requires react_component_hash
export default async (props, _railsContext) => {
const client = createApolloClient();

const componentHtml = await getMarkupFromTree({
tree: <App {...props} client={client} />,
renderFunction: renderToString,
});

const apolloState = client.extract();
const serializedApolloState = JSON.stringify(apolloState)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
const apolloStateTag = `<script>window.__APOLLO_STATE__ = ${serializedApolloState};</script>`;

return {
renderedHtml: {
componentHtml,
apolloStateTag,
},
};
};

Security: If you serialize JSON into an inline <script> tag, escape <, >, and & characters at minimum. Consider using a library like serialize-javascript for comprehensive escaping, so user data cannot break out of the script block with </script> or inject HTML entities.

Code-Splitting with @loadable/component​

If you use @loadable/component with ChunkExtractor to collect code-split chunk tags, this still requires react_component_hash:

export default (props, _railsContext) => {
const extractor = new ChunkExtractor({ statsFile });
const componentHtml = renderToString(extractor.collectChunks(<App {...props} />));

return {
renderedHtml: {
componentHtml,
linkTags: extractor.getLinkTags(),
scriptTags: extractor.getScriptTags(),
styleTags: extractor.getStyleTags(),
},
};
};

Modern alternative: For streaming SSR, consider replacing @loadable/component with React.lazy + Suspense. React 19 hoists <script async src="..."> and <link rel="stylesheet" precedence="..."> automatically, which covers the same use case as ChunkExtractor without needing a render-function.

Migration Decision Matrix​

Use this matrix to decide which approach to use:

Use CaseBeforeAfter
Page title and meta tagsreact-helmet + react_component_hashReact 19 native <title>, <meta>
Canonical URLsreact-helmet + react_component_hashReact 19 native <link rel="canonical">
Open Graph tagsreact-helmet + react_component_hashReact 19 native <meta property="og:...">
Stylesheetsreact-helmet or ChunkExtractorReact 19 native <link rel="stylesheet" precedence="...">
Async script loadingChunkExtractor or manualReact 19 native <script async src="...">
Apollo Client statereact_component_hashKeep react_component_hash (no migration path)
Inline scripts (dangerouslySetInnerHTML)react_component_hashKeep react_component_hash (inline scripts not hoisted)
@loadable/component chunksreact_component_hash + ChunkExtractorConsider React.lazy + Suspense with streaming

Prerequisites​

  • React 19 — native metadata hoisting is a React 19 feature
  • React on Rails 15+ — for basic react_component usage
  • React on Rails Pro 4+ — for stream_react_component and RSC support

References​