History APIによるクライアントサイドルーティングの構築
History APIのバニアルーターを作成。pushState、popstate、動的パラメータ、SEO向けURL、更新時404とXSS対策まで解説。
クライアントサイドルーティングは、URLを更新してJavaScriptで再レンダリングすることでビューを切り替えます。サーバーへのラウンドトリップは発生しません(サーバーへのアクセスは初回ロード時とハードリフレッシュ時のみです)。
シングルページアプリをリリースした経験があれば、あの瞬間に覚えがあるはずです。ローカルではすべて正常に動作していたのに、チームメイトが戻るボタンを押すとURLは変わるのにページはまったく動かない、というあの状況です。どこを見ればよいか分かっていれば5分で直せますが、ほぼ誰もが最初は引っかかります。
React RouterやVue Routerといったフレームワークは、この挙動をコンポーネントやフックでラップしていますが、その内部ではいずれも同じブラウザプリミティブ、すなわちHistory APIを駆動しています。本記事では、約50行で最小限かつ正確でデプロイ可能なバニラルーターを構築し、pushStateとpopstateの役割分担を解説し、さらにおもちゃと実運用可能なものとを分ける2つの落とし穴(デプロイ時の404とXSSインジェクションのリスク)を取り上げます。
要点
- History modeでは、
history.pushState(state, '', url)はページをリロードせずにURLを変更しますが、popstateイベントは発火しません。pushStateの後に自分でレンダリング関数を呼び出し、それとは別に戻る/進む操作に対応するためpopstateをリッスンする必要があります。 - History modeのクリーンな
/dashboard形式のURLはSEOや共有の面で優れていますが、未知のパスをすべてindex.htmlにリライトするようサーバーを設定しなければ、直接アクセスやリフレッシュで404が返ります。 pushStateの第2引数はブラウザが無視するレガシーなtitleパラメータです。省略できないため、常に空文字列を渡してください。innerHTMLによるビューの挿入は、信頼できないデータを埋め込む場合のXSSベクターとなり、また挿入したマークアップ上のイベントリスナーを黙って失わせます。createElementでノードを構築するか、サニタイズするか、テンプレートライブラリを使い、振る舞いはイベント委譲で紐づけましょう。- Navigation APIは2026年1月にBaseline Newly availableに到達し、このパターンの後継として台頭しつつありますが、History APIは依然として最も広い互換性を持つベースラインです。
hash modeとHistory modeの違いは?
クライアントサイドルーティングは、フルページリロードなしにURLの変化に応じてビューを更新します。ナビゲーションを発生させずにURLを変更する方法は2つあります。hash modeとHistory modeです。hash modeはルートを # の後ろにエンコードします(/app#/users)。ハッシュ以降のフラグメントはサーバーに送信されないため、ハッシュベースのナビゲーションは純粋にクライアントサイドで完結し、サーバー設定は一切不要で、hashchange イベントをリッスンします。History modeはHistory APIを使ってクリーンなパス(/users)を生成し、popstate をリッスンします。
| hash mode | History mode | |
|---|---|---|
| URLの形 | /app#/users | /users |
| 変更イベント | hashchange | popstate |
| サーバー設定 | 不要 | 全パスを index.html にリライト |
| リフレッシュ/ディープリンク | 常に動作 | リライトがなければ404 |
| SEO/共有可能なURL | 弱い | クリーンで推奨 |
History modeは、クリーンでインデックス可能なURLが得られるためデフォルトの選択肢であり、本記事もこれを前提に構築します。唯一のコストはサーバー側のサポートが必要になる点で、これは後述します。
実際に必要となるHistory APIのプリミティブ
Discover how at OpenReplay.com.
History modeのルーターを支えるプリミティブは3つです。history.pushState(state, unused, url) はセッション履歴スタックにエントリを追加し、アドレスバーを変更します。history.replaceState は同様の処理を行いますが、エントリを追加する代わりに現在のエントリを上書きします。location.pathname は現在のパスを読み取り、ルートのマッチングに使います。popstate イベントは、ユーザーが戻るまたは進むを押したときに発火します。
決定的なルールはこれです。pushState と replaceState は popstate を発火しません。 すべての pushState の後に自分でレンダリング関数を呼び出し、それとは別に popstate リスナーを登録して、ブラウザの戻る/進むでビューが再レンダリングされるようにしなければなりません。このリスナーを忘れると、戻る操作でURLは変わるのにDOMは固まったままになります。これはコードレビューでは見えないバグですが、アプリのセッションリプレイを見れば一目瞭然です。
さらに2点、重要な詳細があります。中央の引数はブラウザが無視するレガシーな title 値で、省略できないため空文字列を渡します。url は同一オリジンでなければなりません。pushState を呼んでもブラウザはそれをロードせず、オリジンが現在のページと異なる場合は例外を投げます。popstate 自体は古くから存在し信頼性が高く、2015年7月以降すべてのブラウザで利用可能です。
最小限のルーターはどう作る?
動作するHistory modeのルーターには5つの要素が必要です。ルートマップ、location.pathname を読み取ってルートをマッチさせ404フォールバックを持つ resolve 関数、data-link 属性に対するクリック委譲、popstate リスナー、そして初回レンダリングです。完全なファイルは次のとおりです。
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
}
const routes = {
'/': { view: () => '<h1>Home</h1><a href="/users/42" data-link>User 42</a>', title: 'Home' },
'/users/:id': { view: (p) => `<h1>User ${escapeHtml(p.id)}</h1>`, title: 'User' },
'/404': { view: () => '<h1>404 — Not found</h1>', title: 'Not found' },
};
const app = document.getElementById('app');
function match(pathname) {
for (const pattern of Object.keys(routes)) {
const pParts = pattern.split('/');
const uParts = pathname.split('/');
if (pParts.length !== uParts.length) continue;
const params = {};
const ok = pParts.every((part, i) => {
if (part.startsWith(':')) { params[part.slice(1)] = decodeURIComponent(uParts[i]); return true; }
return part === uParts[i];
});
if (ok) return { route: routes[pattern], params };
}
return { route: routes['/404'], params: {} };
}
function resolve() {
const { route, params } = match(location.pathname);
app.innerHTML = route.view(params);
document.title = route.title;
}
function navigate(url) {
history.pushState({}, '', url); // '' is the ignored legacy title
resolve(); // pushState does NOT fire popstate — render manually
}
document.addEventListener('click', (e) => {
const link = e.target.closest('[data-link]'); // robust: works on nested markup
if (!link) return;
e.preventDefault();
navigate(link.getAttribute('href'));
});
window.addEventListener('popstate', resolve); // Back / Forward
history.replaceState({}, '', location.pathname); // seed the initial entry
resolve(); // render on first paint
e.target.closest('[data-link]') によるイベント委譲は意図的なものです。子ノード(リンク内のアイコンなど)へのクリックにも耐え、ビューが再レンダリングされても機能し続けます。各要素にリスナーを付ける方法や、属性の順序に依存しネストしたマークアップで壊れる e.target.attributes[0] を読む方法とは対照的です。
レベルアップ:動的パラメータ、タイトル、初期エントリ
上記の match 関数はすでに動的セグメントを扱えます。/users/:id のようなパターンはパーツに分割され、: で始まるセグメントは対応するパスセグメントを params オブジェクトにキャプチャします。したがって /users/42 は { id: '42' } として解決されます。: 以外のセグメントは完全一致する必要があり、長さが一致しないパターンはスキップされるため、/users が /users/42 にマッチすることはありません。resolve 内で document.title を設定すると、ナビゲーションのたびにタブと履歴のラベルが更新されます。
もう1つ、ルーターに入れるべき修正があります。ブラウザは通常のページロードから最初の履歴エントリを作成するため、そこには何も保存されていません。MDNのHistory API活用ガイドでは、起動時に history.replaceState() を呼び出してそのエントリにstateを付与することを推奨しています。そうすれば、最初の戻る操作で開始時のビューを復元できます。これがルーター末尾の replaceState の行です。
おもちゃと本物のルーターを分ける2つの落とし穴
デプロイ。 History modeのクリーンなURLでは、未知のパスをすべて index.html にリライトするようサーバーを設定する必要があります。さもないと /users/42 への直接アクセスやリフレッシュは404を返します。リクエストはバンドルがロードされる前にサーバーへ到達するため、JavaScriptによる回避策は存在しません。ホストごとに一度リライトを設定しましょう。Express 5ではパスマッチング構文が変更され、すべてのワイルドカードに名前を付ける必要があります。そのため、従来のキャッチオール app.get('*') は起動時に “Missing parameter name” エラーを投げます。ルートパスとその配下すべてにマッチする、波括弧付きの名前付きワイルドカードを使ってください。
// Express 5.x
app.get('/{*splat}', (req, res) => res.sendFile(__dirname + '/public/index.html'));
// Express 4.x used: app.get('*', ...)
# Nginx
location / { try_files $uri $uri/ /index.html; }
# Netlify — _redirects
/* /index.html 200
// Vercel — vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
共有されたディープリンクでのハードリフレッシュ時の404も、コード上は問題なく見えるのに、実際のセッションが空白ページに着地する様子を見れば明白になるタイプの障害です。
セキュリティ。 innerHTML によるビューの挿入は、信頼できないデータが埋め込まれる場合(上記の ${p.id} はURLから直接来ています)常にXSSベクターとなり、さらに挿入したマークアップ上のイベントリスナーを黙って失わせます。埋め込む値をエスケープする(上記の escapeHtml の呼び出し)、document.createElement でノードを構築する、あるいは lit-html のようなテンプレートライブラリを使い、振る舞いは挿入されたノードではなく安定した親要素へのイベント委譲で紐づけましょう。補間を含まない静的で開発者が記述したテンプレート文字列自体はインジェクションではありません。リスクは、そこに差し込む信頼できないデータにあります。
プラットフォームの向かう先:Navigation API
Navigation API は、Firefox 147がサポートを追加した2026年1月にBaseline Newly availableに到達し、このパターンの後継として台頭しつつあります。pushState、popstate リスナー、クリックハンドラを個別に組み上げる代わりに、1つの navigate リスナーを登録します。これはページが認識できるあらゆるナビゲーションに対して、何が起点であっても実行され、そのリスナー内で event.intercept() を呼び出せば、アドレスバーと履歴スタックの管理はブラウザに任せられます。このAPIが解決する欠点の1つが、プログラムによる pushState/replaceState では popstate が発火しないという点、まさにこのルーターが回避している摩擦です。対象ブラウザの互換性の下限になるまでは、History APIが最も広くサポートされたベースラインであり、ルーターが実際に何をしているのかを理解する最も明快な方法であり続けます。
これで、実行可能なHistory modeのルーターが手に入りました。ルート、パラメータマッチング、委譲されたクリック、正しい popstate の処理、初期化された初期エントリ、そして本番向けの2つの修正が揃っています。次の具体的なステップは、デプロイ前に自分のホスト向けのサーバーリライトを設定し、ディープリンクがリフレッシュに耐えられるようにすることです。
FAQ
SPAで戻るボタンを押すとURLは変わるのにページが変わらないのはなぜですか?
pushStateとreplaceStateはpopstateイベントを発火しないためです。クリックハンドラの中でのみレンダリングしていてpopstateリスナーを登録していないと、戻る/進む操作ではアドレスバーだけが更新され、再レンダリングは行われません。修正方法は、ブラウザが履歴を移動するたびにレンダリング関数を実行する、独立したwindow.addEventListener('popstate', resolve)を用意することです。セッションリプレイを見れば、URLが変化してもDOMが変化していない様子が確認できます。
pushStateとreplaceStateの違いは何ですか?
pushStateはセッション履歴スタックに新しいエントリを追加するため、前のビューには戻るボタンでアクセスできます。replaceStateはエントリを追加する代わりに現在のエントリを上書きするため、新しい「戻る」先を作りません。通常のナビゲーションにはpushStateを、起動時に初期ページのエントリを初期化する場合や履歴を汚さずに現在のURLを修正する場合にはreplaceStateを使います。どちらも同じ (state, unused, url) のシグネチャを持ち、いずれもpopstateを発火しません。
hash modeのルーティングにサーバー設定は必要ですか?
いいえ。ハッシュ以降のフラグメント、たとえば '/app#/users' の '/users' の部分はサーバーに送信されないため、ハッシュベースのナビゲーションは純粋にクライアントサイドで完結し、リライトルールなしで任意の静的ホストで動作します。サーバーは常に '/app' しか見ないため、リフレッシュやディープリンクも必ず解決されます。History modeはそのトレードオフで、よりクリーンなURLを生成しますが、未知のパスをすべてindex.htmlにリライトするようサーバーを設定しなければリフレッシュで404が返ります。
Navigation APIがBaselineになった今でもHistory APIを学ぶべきですか?
はい。Navigation APIは2026年1月にBaseline Newly availableに到達し、手動のpushState、popstate、クリックの傍受を単一のnavigateイベントとevent.intercept()で置き換える後継として台頭しつつあります。しかしHistory APIは依然として最も広い互換性を持つベースラインであり、Navigation APIが動作しない古いブラウザでも動作し、React RouterやVue Routerといったフレームワークが今も内部で駆動しているものです。これを学ぶことは、あらゆるルーターが実際に何をしているのかを理解する最も明快な方法です。