Reactで、見出しからURL用のスラッグを作る方法
見出しからスラッグを作るには、normalize で文字の形をそろえ、文字と数字以外を - に置き換えます。日本語の見出しは日本語のまま残すので、英数字だけを残す書き方と違い、スラッグが空になりません。
React で使うときは、同じ見出しが2つあっても id が重ならないように、すべての見出しのスラッグを一度に作ります。もう1つ、URL に # を付けて開いたときに見出しの位置までスクロールしなかったので、描画のあとでスクロールする処理を足しました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。tsc で型エラーがないことを確かめ、コンポーネントを StrictMode で包んで esbuild で開発用にビルドし、Playwright で操作しました。サーバー側での描画(SSR)、Next.js などのフレームワークのルーター、Firefox と Safari は試していません。
見出しをスラッグにする関数
// 見出しの文字列から、URL に使うスラッグを作る関数
export function slugify(text: string): string {
return text
.normalize('NFKC') // 全角の英数字を半角にする(React → React)
.toLowerCase()
.normalize('NFD') // é を e とアクセント記号に分ける
.replace(/[\u0300-\u036f]/g, '') // ラテン文字のアクセント記号だけを消す
.normalize('NFC') // 分かれた濁点を元に戻す(テ+結合用の濁点 → デ)
.replace(/[^\p{L}\p{N}]+/gu, '-') // 文字と数字以外のつながりを、1つの - にする
.replace(/^-+|-+$/g, ''); // 先頭と末尾の - を消す
}
// 見出しの一覧から、重ならないスラッグの一覧を作る
export function uniqueSlugs(titles: readonly string[]): string[] {
const used = new Set<string>();
return titles.map((title) => {
const base = slugify(title) || 'section'; // 記号だけの見出しは section にする
let slug = base;
for (let n = 2; used.has(slug); n++) slug = `${base}-${n}`;
used.add(slug);
return slug;
});
}
slugify は、次の順に文字列を変えていきます。
normalize('NFKC')で、全角の英数字を半角にするtoLowerCase()で、英字を小文字にするnormalize('NFD')でアクセント付きの文字を分け、U+0300〜U+036F のアクセント記号を消すnormalize('NFC')で、分かれたままの文字を合成し直す- 文字(
\p{L})と数字(\p{N})以外が続くところを、1つの-にする - 先頭と末尾の
-を消す
normalize に渡す4つの形の意味は、MDN の String.prototype.normalize() の説明にまとまっています。\p{L} のような書き方は Unicode 文字クラスエスケープといい、正規表現に u フラグ(または v フラグ)を付けたときだけ使えます。\p{L} はひらがなや漢字も文字として扱うので、日本語の見出しは日本語のまま残ります。
Node.js で動かすと、次のようになりました。
| 見出し | スラッグ |
|---|---|
| Getting Started | getting-started |
| React の useState とは? | react-の-usestate-とは |
| ReactとTypeScript | reactとtypescript |
| データベースの設計 | データベースの設計 |
| Café au lait | cafe-au-lait |
| Node.js 22 の新機能 | node-js-22-の新機能 |
| API_KEY を設定する | api-key-を設定する |
| 第2章:まとめ | 第2章-まとめ |
| 🐱 猫の手 | 猫の手 |
| C++ と C# | c-と-c |
| ★☆★ | (空の文字列) |
日本語の文字のあいだには空白がないので、「データベースの設計」は1つのまとまりのまま残ります。一方で、記号はすべて消えます。「C++ と C#」が「c-と-c」になるように、記号で区別していた見出しは区別がつかなくなります。「★☆★」のように記号だけの見出しは空の文字列になるので、上のコードの uniqueSlugs で section という名前を付けます(後の節「同じ見出しが2つあるときは、番号を付ける」で説明します)。
英数字だけを残すと、日本語の見出しが消える
英小文字と数字以外をすべて - にする書き方もあります。比べるために、/[^a-z0-9]+/g で置き換える書き方でも試しました。
| 見出し | 英数字だけを残す書き方 |
|---|---|
| Getting Started | getting-started |
| React の useState とは? | react-usestate |
| インストール | (空の文字列) |
| 使い方 | (空の文字列) |
英語の見出しなら同じ結果ですが、日本語だけの見出しは空になりました。英数字だけのスラッグにしたいときは、記事の最後で作る入力欄のように、手で付けられるようにしておきます。
アクセント記号だけを消す理由
「Café」の「é」を「e」にするには、NFD で「e」とアクセント記号に分けてから、記号を消します。このとき、記号を \p{M}(結合文字すべて)で消すと、日本語の濁点まで消えました。
| 見出し | \p{M} をすべて消す書き方 |
|---|---|
| Café au lait | Cafe au lait |
| データベースの設計 | テータヘースの設計 |
NFD は「デ」を「テ」と濁点に、「ベ」を「ヘ」と濁点に分けます。濁点も結合文字なので、\p{M} で消すと「テータヘース」になります。slugify では、消す範囲をラテン文字などに付くアクセント記号(U+0300〜U+036F)に絞っています。
範囲を絞っても、分けた濁点は「テ」と濁点のまま残っています。最後の normalize('NFC') を省くと、「データベース」のスラッグは、見た目は同じでも長さが8文字になり、NFC で合成し直した6文字の文字列とは別のものになりました。見出しの id と目次のリンクで作り方が少しでも違うと、見た目が同じでも一致しなくなります。
同じ見出しが2つあるときは、番号を付ける
uniqueSlugs は、見出しの一覧を受け取り、2回目に出てきたスラッグに -2、3回目に -3 を付けます。結果は次のとおりです。
| 見出しの一覧 | スラッグの一覧 |
|---|---|
| 使い方、使い方、使い方 | 使い方、使い方-2、使い方-3 |
| 例、例、例-2 | 例、例-2、例-2-2 |
| ★、☆、まとめ | section、section-2、まとめ |
3つ目の「例-2」は、2つ目の見出しに付けた「例-2」と重なるので、さらに -2 を付けて「例-2-2」にしています。番号を付けたあとのスラッグも used に入れ、使われていない名前が見つかるまで番号を進めるので、もとから番号の付いた見出しがあっても重なりません。
目次と見出しに id を付ける
import { useEffect } from 'react';
import { uniqueSlugs } from './slugify';
type Section = { title: string; body: string };
// 見出しに id を付け、目次からその見出しへ移れるようにした記事
export function Article({ sections }: { sections: readonly Section[] }) {
// 重なりを調べるため、すべての見出しのスラッグを一度に作る
const slugs = uniqueSlugs(sections.map((s) => s.title));
// 見出しを描画したあとで、URL の # が指す見出しへ移る
useEffect(() => {
let id = '';
try {
id = decodeURIComponent(location.hash.slice(1));
} catch {
return; // %E4 のような壊れた形は、何もしない
}
if (id) document.getElementById(id)?.scrollIntoView();
}, []);
return (
<article>
<nav aria-label="目次">
<ol>
{sections.map((s, i) => (
<li key={slugs[i]}>
<a href={`#${slugs[i]}`}>{s.title}</a>
</li>
))}
</ol>
</nav>
{sections.map((s, i) => (
<section key={slugs[i]}>
<h2 id={slugs[i]}>
{s.title}
<a className="anchor" href={`#${slugs[i]}`} aria-label={`「${s.title}」へのリンク`}>
#
</a>
</h2>
<p>{s.body}</p>
</section>
))}
</article>
);
}
見出しの # のリンクと、後で作る入力欄の見た目には、次の CSS を使いました。
.slug-field {
display: grid;
gap: 6px;
max-width: 360px;
}
.slug-field input {
font-size: 1rem;
padding: 6px 8px;
}
.slug-field output {
margin-top: 8px;
overflow-wrap: anywhere;
}
h2 .anchor {
margin-left: 0.4em;
color: #067a98;
text-decoration: none;
}
スラッグは、sections のすべての見出しから一度に作っています。slugify は同じ見出しに同じスラッグを返すので、見出しのコンポーネントの中で1つずつ slugify を呼ぶと、同じ見出しの id が重なります。目次のリンクの href と見出しの id は、同じ配列 slugs から取るので、作り方がずれることもありません。
スラッグは見出しの文字だけから決まり、見出しの並び順は使いません(同じ見出しに付ける番号だけは、出てきた順で決まります)。毎回同じ値になり、同じ一覧の中で重ならないので、リストの key にもこのスラッグを使っています。key が兄弟の間で一意で、変わらないものであるべきことは、React の公式ドキュメントにある key のルールに書かれています。
「インストール」「使い方」「React の useState とは?」「使い方」「Q&A」の5つの見出しで描画すると、h2 の id は「インストール」「使い方」「react-の-usestate-とは」「使い方-2」「q-a」になりました。
目次の「React の useState とは?」と2つ目の「使い方」のリンクを押すと、どちらもその見出しが画面の上端に来ました。「React の useState とは?」を押したあとの location.hash は #react-%E3%81%AE-usestate-%E3%81%A8%E3%81%AF でした。日本語の部分は、ブラウザがパーセントエンコードしています。この形は、encodeURIComponent でスラッグを変えた結果と同じです。href には日本語のまま書いておけば、エンコードはブラウザが行います。
URL の # で開いたときに、見出しまでスクロールしない
useEffect の部分がないと、#使い方-2 を付けた URL を新しいタブで開いても、ページは先頭のままでした。
Playwright で調べると、ページの load イベントの時点では、h2 が1つもありませんでした。この検証のページは、createRoot で見出しを描画しています。見出しが画面に出たのは、load イベントよりあとでした。
そこで、見出しを描画したあとの useEffect で、location.hash が指す見出しを探して scrollIntoView でその位置までスクロールします。location.hash はパーセントエンコードされた形で入っているので、decodeURIComponent で日本語に戻してから getElementById に渡します。decodeURIComponent は %E4 のように壊れた形を渡すと URIError を投げるので、try で囲んでいます。
useEffect を足したあとは、#%E4%BD%BF%E3%81%84%E6%96%B9-2 のようにエンコードした URL でも、#使い方-2 と日本語のまま書いた URL でも、開くと「使い方-2」の見出しが画面の上端に来ました。#%E4 で開いたときは、エラーを出さずにページの先頭のままでした。
タイトルからスラッグを作る入力欄
記事の URL に使うスラッグは、タイトルから作ったものをそのまま使うとは限りません。タイトルを打つとスラッグが入り、手で直したらそちらを使う入力欄を作りました。
import { useId, useState } from 'react';
import { slugify } from './slugify';
// タイトルを打つとスラッグが入り、手で直したらそれを使う入力欄
export function SlugField() {
const titleId = useId();
const slugId = useId();
const [title, setTitle] = useState('');
// 手で直したスラッグ。null のあいだは、タイトルから作る
const [customSlug, setCustomSlug] = useState<string | null>(null);
const slug = customSlug ?? slugify(title);
return (
<div className="slug-field">
<label htmlFor={titleId}>タイトル</label>
<input id={titleId} value={title} onChange={(e) => setTitle(e.target.value)} />
<label htmlFor={slugId}>スラッグ</label>
<input id={slugId} value={slug} onChange={(e) => setCustomSlug(e.target.value)} />
<button type="button" onClick={() => setCustomSlug(null)} disabled={customSlug === null}>
タイトルから作り直す
</button>
<output>https://example.com/posts/{encodeURIComponent(slug)}</output>
</div>
);
}

スラッグの欄に出す値は、state に入れずに、描画のたびに customSlug ?? slugify(title) で決めています。公式ドキュメントの「そのエフェクトは不要かも」にあるとおり、ほかの state から計算できるものは、state に入れずに描画中に計算します。useEffect でタイトルの変化を見てスラッグの state を書き換える必要はありません。
手で直したかどうかは、customSlug が null かどうかで分けます。Playwright で操作した結果は次のとおりです。
| 操作 | スラッグの欄 | 「タイトルから作り直す」 |
|---|---|---|
| タイトルに「React の useState とは?」と入れる | react-の-usestate-とは | 押せない |
| スラッグを「react-usestate」に直す | react-usestate | 押せる |
| タイトルを「React の useState とは?(改訂版)」に変える | react-usestate | 押せる |
| 「タイトルから作り直す」を押す | react-の-usestate-とは-改訂版 | 押せない |
| タイトルを「React の useState 入門」に変える | react-の-usestate-入門 | 押せない |
スラッグを手で直したあとは、タイトルを変えてもスラッグは変わりませんでした。ボタンを押すと customSlug が null に戻り、またタイトルに合わせて変わるようになりました。
欄の下には、encodeURIComponent で変えた URL を出しています。「react-の-usestate-とは」は、URL に入れると react-%E3%81%AE-usestate-%E3%81%A8%E3%81%AF の43文字になりました。日本語のスラッグは、パーセントエンコードした形では長くなります。英数字の URL にしたいときは、スラッグの欄で手で直します。





