Skip to main content

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

Use async props to stream slow data from Rails while showing a loading shell immediately. These APIs are available in React on Rails Pro: the parent component calls getReactOnRailsAsyncProp to obtain a Promise for each slow prop, passes it to an async child that awaits it, and wraps that child in <Suspense>:

React on Rails Pro setup: This async-props helper is Pro-only. The controller must include ReactOnRailsPro::Stream and render the view via stream_view_containing_react_components (see Streaming SSR), and RSC must be enabled with config.enable_rsc_support = true in config/initializers/react_on_rails_pro.rb (see React on Rails Pro configuration).

Server Component boundary: Async function components such as UserProfile must run in a Server Component tree. Keep this file free of a 'use client' directive. If resolved data needs to cross into a Client Component, await it in the server component first and pass only serializable props.

import React, { Suspense } from 'react';

// ProfilePage.jsx (no 'use client' directive; this is a Server Component tree)
const UserProfile = async ({ userPromise, siteName }) => {
const user = await userPromise;

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

const ProfileSkeleton = () => <p>Loading profile...</p>;

const ProfilePage = ({ getReactOnRailsAsyncProp, siteName }) => {
const userPromise = getReactOnRailsAsyncProp('user');

return (
<div>
{/* Initial metadata shown while loading */}
<title>{`Loading Profile... | ${siteName}`}</title>
<meta property="og:site_name" content={siteName} />

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

export default ProfilePage;

Production note: Error Boundaries help with client-side RSC payload fetches, refetches, and render-time failures after the initial stream. They do not catch errors thrown during the initial Server Component HTML stream; handle Rails/server-side failures before or inside emit.call with Rails-side rescue, logging, and serializable fallback values. See Error Boundary limitations and the client-side retry pattern.

<%# Rails view — controller authorizes @user and captures request-scoped values first %>
<% site_name = Current.account.name %>

<%= stream_react_component_with_async_props("ProfilePage",
props: { siteName: site_name }) do |emit|
emit.call("user", @user.as_json(only: %i[name bio]))
end %>

React on Rails note: Keep authorization, database access, and cache-aware data loading in Rails. When auth, tenancy, or request state lives in CurrentAttributes, capture the needed serializable values before entering async-props work, pass them as regular props or IDs, and resolve scoped records server-side before calling emit.call. The React component never fetches data itself; it awaits the Promise that getReactOnRailsAsyncProp returns. See RSC data fetching.

The initial <title> ("Loading Profile...") appears immediately. When Rails emits the user prop via emit.call, the Promise resolves, UserProfile renders, and React replaces the title with the user-specific one.

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. Keep canonical URLs and any SEO-critical tags that Rails already knows in the first-wave shell, then use React on Rails Pro async props so that slower article content and metadata can stream in while the shell renders immediately:

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

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

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

const ArticlePage = ({ getReactOnRailsAsyncProp, canonicalUrl }) => {
const articlePromise = getReactOnRailsAsyncProp('article');

return (
<div>
<title>Loading...</title>
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
<Suspense fallback={<ArticleSkeleton />}>
<AsyncContent articlePromise={articlePromise} />
</Suspense>
</div>
);
};

export default ArticlePage;
<%# Rails view — controller authorizes @article and prepares first-wave SEO props %>
<% canonical_url = @article.canonical_url %>

<%= stream_react_component_with_async_props("ArticlePage",
props: { canonicalUrl: canonical_url }) do |emit|
emit.call("article", @article.as_json(
only: %i[title excerpt cover_image body]
))
end %>

React on Rails note: RSC server components still run as part of a Rails-rendered request. Pass canonical URLs, tenant IDs, viewer IDs, and other first-wave context as explicit serializable props, or resolve the scoped records in Rails before streaming them through emit.call. The React component only awaits the Promise from getReactOnRailsAsyncProp. See RSC data fetching.

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