React 19で、titleやmetaをコンポーネントの中に書く方法

​​React​meta,title,TypeScript,サーバーレンダリング

React 19 では、<title> や <meta> を、ページの部品(コンポーネント)の JSX にそのまま書けます。どこに書いても、React が <head> の中へ移してくれるので、useEffect で document.title を書き換えたり、ライブラリを入れたりしなくても、ページごとのタイトルと説明文を出せます。

筆者は検証環境で、ページを切り替えたときの <head> の中身、index.html に最初からある <title> との関係、2つの部品が <title> を書いたときの動き、空のタイトルになってしまう書き方、<head> に移されない書き方、サーバーで描いたときの出力を確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react / @types/react-dom 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で、開発用ビルドの React で動かしました。GIF は、同じコンポーネントを Playwright 1.56.1 の Chromium で動かして撮ったものです。

ページの部品の中に title と meta を書く

React 19 の発表記事にある、ドキュメントのメタデータについての節によると、これまでは <title>、<link>、<meta> を <head> に入れるには、エフェクトの中で手で入れるか、react-helmet のようなライブラリを使う必要がありました。React 19 からは、部品の中に書いたこれらのタグを、React が <head> へ移します。

次の Pages は、ボタンで2つのページを切り替えます。それぞれのページの部品が、<article> の中に自分の <title>、説明文の <meta>、正規の URL を示す <link rel="canonical"> を書いています。

tsx
import { useState } from 'react';

// ページごとの部品が、自分の title・meta・link を描く
function HomePage() {
  return (
    <article>
      <title>トップ | 猫の手ブログ</title>
      <meta name="description" content="猫の手ブログのトップページです。" />
      <link rel="canonical" href="https://example.com/" />
      <h1>トップ</h1>
    </article>
  );
}

export function CatPage() {
  return (
    <article>
      <title>猫の記事 | 猫の手ブログ</title>
      <meta name="description" content="猫についての記事です。" />
      <link rel="canonical" href="https://example.com/cats" />
      <h1>猫の記事</h1>
    </article>
  );
}

export function Pages() {
  const [page, setPage] = useState<'home' | 'cats' | 'none'>('home');
  return (
    <>
      <nav>
        <button onClick={() => setPage('home')}>トップ</button>
        <button onClick={() => setPage('cats')}>猫の記事</button>
        <button onClick={() => setPage('none')}>どちらも出さない</button>
      </nav>
      {page === 'home' && <HomePage />}
      {page === 'cats' && <CatPage />}
    </>
  );
}

Vite の React テンプレートの index.html では、<head> に最初から <title> が書かれています。この検証でも、<head> に <title>初期のタイトル</title> を書いた HTML の上で描きました。最初に描いたあとの <head> と、#root の中身は次のとおりです。

text
--- 最初の描画
  document.title = "トップ | 猫の手ブログ"
  head: <meta charset="utf-8"><title>トップ | 猫の手ブログ</title><title>初期のタイトル</title><meta name="description" content="猫の手ブログのトップページです。"><link rel="canonical" href="https://example.com/">
  body: <nav><button>トップ</button><button>猫の記事</button><button>どちらも出さない</button></nav><article><h1>トップ</h1></article>

3つのタグは <article> の中から消え、<head> に入っています。<head> に最初からあった <title>初期のタイトル</title> は消えずに残り、React の <title> はその前に入りました。HTML の仕様の document.title の定義では、document.title は文書の中で最初に出てくる <title> の文字を返します。そのため、React の書いたタイトルになっています。

ページを切り替えると、head の中身も入れ替わる

ボタンを押してページを切り替えると、<head> の中身もページに合わせて入れ替わります。次の GIF は、同じ Pages を Chromium で動かし、押すたびに document.title と <head> の中身を書き出したものです。

ボタンを3回押し、そのたびに document.title と head の中身が変わる様子
「猫の記事」「どちらも出さない」「トップ」の順に押したときの、document.title と head の中身。変わった欄をピンクで示しています

jsdom で同じ順に押したときの出力のうち、1回目と2回目のあとの document.title と <head> を写します。

text
--- 「猫の記事」を押したあと
  document.title = "猫の記事 | 猫の手ブログ"
  head: <meta charset="utf-8"><title>猫の記事 | 猫の手ブログ</title><title>初期のタイトル</title><meta name="description" content="猫についての記事です。"><link rel="canonical" href="https://example.com/cats">
--- 「どちらも出さない」を押したあと
  document.title = "初期のタイトル"
  head: <meta charset="utf-8"><title>初期のタイトル</title>

ページの部品がなくなると、その部品の書いたタグも <head> から消えました。<title> を書く部品が1つもないときは、残っていた 初期のタイトル が document.title に戻ります。アプリ全体をアンマウントしたときも同じでした。index.html の <title> は、React の部品がタイトルを書かないあいだの代わりとして働きます。

2つの部品が title を書くと、両方が head に入る

サイト共通のレイアウトと、その中のページの両方が <title> と説明文の <meta> を書くと、どうなるかを試しました。

tsx
import type { ReactNode } from 'react';

// 共通のレイアウトと、その中のページの両方が title と description を描く
function Layout({ children }: { children: ReactNode }) {
  return (
    <div className="layout">
      <title>猫の手ブログ</title>
      <meta name="description" content="サイト共通の説明です。" />
      {children}
    </div>
  );
}

function CatPage() {
  return (
    <article>
      <title>猫の記事 | 猫の手ブログ</title>
      <meta name="description" content="猫についての記事です。" />
      <h1>猫の記事</h1>
    </article>
  );
}

export function TwoTitles() {
  return (
    <Layout>
      <CatPage />
    </Layout>
  );
}
text
--- 最初の描画
  document.title = "猫の記事 | 猫の手ブログ"
  head: <meta charset="utf-8"><title>猫の記事 | 猫の手ブログ</title><title>猫の手ブログ</title><title>初期のタイトル</title><meta name="description" content="サイト共通の説明です。"><meta name="description" content="猫についての記事です。">

React は、どちらかを選んで片方を消すことはしません。<title> は3つ、description の <meta> は2つ、<head> に並びました。この検証では document.title がページの側のタイトルになりましたが、<title> のリファレンスの注意書きには、複数の部品が同時に <title> を書いたときのブラウザや検索エンジンの動きは決まっていない、とあります。並び順に頼らず、<title> を書く部品は、そのとき1つだけにしておきます。

ルートに合わせて共通の値を上書きしたいときは、ライブラリのほうが向いています。発表記事の補足でも、単純な使い方ならタグを書くだけで足りるが、ルートに応じて共通のメタデータを上書きするような機能はライブラリのほうが強い、と書かれています。

title の中で {} を使うと、空のタイトルになる

ページ番号のような値をタイトルに混ぜるとき、JSX の {} をそのまま使うと、タイトルが空になります。

tsx
import { useState } from 'react';

// 🔴 {} で数を混ぜると、children が文字列1つにならない
export function TitleWithNumber() {
  const [page, setPage] = useState(1);
  return (
    <>
      <title>検索結果 {page}ページ目</title>
      <button onClick={() => setPage(page + 1)}>次へ</button>
    </>
  );
}
text
--- 最初の描画
  document.title = ""
  head: <meta charset="utf-8"><title></title><title>初期のタイトル</title>
  body: <button>次へ</button>
--- 「次へ」を押したあと
  document.title = ""
  head: <meta charset="utf-8"><title></title><title>初期のタイトル</title>
  body: <button>次へ</button>

<title> のリファレンスの、変数をタイトルに使う節によると、<title> の children は文字列1つでなければなりません。検索結果 {page}ページ目 と書くと、children は文字列と数を並べた配列になります。

注意

ブラウザ向けの描画(react-dom/client)では、開発用ビルドでも警告が出ず、<title> が空のまま <head> に入りました。index.html のタイトルより前に入るため、document.title は空になります。

同じ部品をサーバーで描くと(renderToString)、開発用ビルドでは次のエラーがコンソールに出ました(長いので、最初の2文だけを写しています)。

text
[console.error] React expects the `children` prop of <title> tags to be a string, number, bigint, or object with a novel `toString` method but found an Array with length 3 instead. Browsers treat all child Nodes of <title> tags as Text content and React expects to be able to convert `children` of <title> tags to a single string value which is why Arrays of length greater than 1 are not supported.

この文言を含むファイルを react-dom の開発用ビルドの中で探すと、見つかったのはサーバー向けのファイルだけでした。サーバーで描かないアプリでは、この警告に頼れません。

直すには、テンプレートリテラルで文字列1つにしてから渡します。次のコードは、さきほどの TitleWithNumber と同じファイルの続きで、useState はその先頭で読み込んでいます。

tsx
// テンプレートリテラルで、文字列1つにしてから渡す
export function TitleWithTemplate() {
  const [page, setPage] = useState(1);
  return (
    <>
      <title>{`検索結果 ${page}ページ目`}</title>
      <button onClick={() => setPage(page + 1)}>次へ</button>
    </>
  );
}
text
--- 最初の描画
  document.title = "検索結果 1ページ目"
--- 「次へ」を押したあと
  document.title = "検索結果 2ページ目"

head に移されないもの

<title> のリファレンスと<meta> のリファレンスには、<head> へ移さない例外が書かれています。<svg> の中の <title> は文書のタイトルではなく図の説明なので、そのまま残ります。itemProp を付けた <title> や <meta> も、文書全体ではなくページの一部についての情報なので、書いた場所に残ります。

tsx
// head に移されないもの:svg の中の title と、itemProp を付けた meta
export function Exceptions() {
  return (
    <section itemScope itemType="https://schema.org/Product">
      <svg width="24" height="24" role="img">
        <title>肉球のアイコン</title>
        <circle cx="12" cy="12" r="10" />
      </svg>
      <meta itemProp="name" content="猫じゃらし" />
      <h2>猫じゃらし</h2>
    </section>
  );
}
text
--- 最初の描画
  document.title = "初期のタイトル"
  head: <meta charset="utf-8"><title>初期のタイトル</title>
  body: <section itemscope="" itemtype="https://schema.org/Product"><svg width="24" height="24" role="img"><title>肉球のアイコン</title><circle cx="12" cy="12" r="10"></circle></svg><meta itemprop="name" content="猫じゃらし"><h2>猫じゃらし</h2></section>

どちらも <section> の中に残り、<head> は変わりませんでした。なお、<link> のリファレンスによると、<link rel="stylesheet"> は precedence を付けたときだけ <head> に移され、onLoad や onError を付けた <link> は移されません。スタイルシートの扱いは、この記事では試していません。

サーバーで描いたときの出力

<html> から描くなら、サーバーで描くときも、同じ書き方で <head> に入ります。次の Document を、react-dom/server の renderToReadableStream で HTML にしました。<head> には、文字コードの <meta> だけを書いています。

tsx
import { CatPage } from './Pages';

// サーバーで描く文書全体。head には charset だけを書いておく
export function Document() {
  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
      </head>
      <body>
        <CatPage />
      </body>
    </html>
  );
}
text
--- 1. html から描いた文書全体を renderToReadableStream で
<!DOCTYPE html><html lang="ja"><head><meta charSet="utf-8"/><title>猫の記事 | 猫の手ブログ</title><meta name="description" content="猫についての記事です。"/><link rel="canonical" href="https://example.com/cats"/></head><body><article><h1>猫の記事</h1></article></body></html>

<body> の中の CatPage が書いた3つのタグが、<head> の中に出力されました。ページごとのタイトルと説明文が、サーバーが返す HTML の <head> に最初から入ります。

<html> を含まない CatPage だけを renderToString で描くと、3つのタグは <article> の外に出て、出力の先頭に並びました。

text
--- 2. ページの部品だけを renderToString で
<title>猫の記事 | 猫の手ブログ</title><meta name="description" content="猫についての記事です。"/><link rel="canonical" href="https://example.com/cats"/><article><h1>猫の記事</h1></article>
ポイント

<title>、<meta>、<link> は、ページの部品の中に書けば、ブラウザでも、<html> から描くサーバーの描画でも、React が <head> に入れます。<title> は、その画面で1つの部品だけが書き、中身はテンプレートリテラルで文字列1つにして渡します。

​Posted by 86