Cloudflare Pagesで末尾スラッシュ(/)へのリダイレクトをなくす方法

Cloudflare Pagesに静的サイトを置くと、/xのURLが/x/へリダイレクトされることがあります。原因であるx/index.htmlのファイル構成を、ビルド後にx.htmlへ変換して末尾スラッシュなしのURLを維持した方法をまとめます。

Cloudflare Pagesで/xが/x/へリダイレクトされる問題

このブログはCloudflare Pagesに置いている静的サイトです。静的サイトとは、リクエストのたびにサーバーでページを作るのではなく、ビルド時にすべてのページをHTMLファイルとして作っておき、そのファイルをそのまま返すサイトのことです。

最初はNext.jsの静的エクスポート(export)で作っていて、記事のURLはhttps://sncstudio.net/win-acme-validation-portのように末尾に/が付かない形でした。検索エンジンにもこのURLで登録されていました。

ところがサイトをReact Router 8に移行してデプロイしたところ、以前のURLにアクセスすると末尾に/が付いたURL(/win-acme-validation-port/)へ移動するようになりました。ページ自体は表示されるので大きな問題には見えませんが、検索の面では困ります。

  • 検索エンジンに登録されたすべての記事URLが、一度リダイレクト(別のURLへの自動移動)を経由することになります。
  • 検索エンジンは/xと/x/を別のURLとして扱います。これまでの評価が新しいURLに引き継がれるまで時間がかかり、サイトマップやcanonical(正規URLの指定)タグと実際のURLもずれてしまいます。

そこで、URLは以前どおり末尾スラッシュなしで維持することにしました。

原因:Cloudflare PagesのHTMLファイルとURLの対応ルール

原因はビルド成果物のファイル構成にありました。Cloudflare Pagesはリクエストされたパスに合うHTMLファイルを返しますが、ファイル名の形によって「正規のURL」を決めています1。

ビルド成果物のファイルCloudflare Pagesでの正規URL別のURLでリクエストした場合
contact.html/contact/contact.html → /contactへ移動
about/index.html/about//about → /about/へ移動

Next.jsの静的エクスポートはwin-acme-validation-port.htmlのように1つ目の形でファイルを作っていました。一方、React Routerのprerender(ビルド時にページをHTMLとして事前生成する機能)はwin-acme-validation-port/index.htmlのように2つ目の形で作ります。そのため同じページなのに、Cloudflare Pagesは末尾に/が付いた方を正規URLとみなしてリダイレクトしていたのです。

つまり、URLを変えるにはCloudflareの設定ではなくファイル名を変えればよいということです。

ビルド後にx/index.htmlをx.htmlへ平坦化する

React Routerのビルドが終わった後、成果物フォルダを走査してx/index.htmlをx.htmlへ移すスクリプトを追加しました。フォルダの中のファイルを一つ上の階層に出す処理なので、ここでは「平坦化」と呼びます。

package.jsonのビルドコマンドの最後にこのスクリプト(scripts/postbuild.ts)を付け足しました。このブログはbunで実行しています。

"build": "bun scripts/contentlayer.ts && react-router build && bun scripts/postbuild.ts",

scripts/postbuild.tsの全体は次のとおりです。React Routerの成果物(build/client)をCloudflare Pagesにアップロードするoutフォルダへコピーし、その後ファイル名を変えます。

import {cpSync, existsSync, readdirSync, renameSync, rmSync, mkdirSync, statSync} from "node:fs";
import path from "node:path";

const SRC = 'build/client';
const OUT = 'out';

rmSync(OUT, {recursive: true, force: true});
cpSync(SRC, OUT, {recursive: true});
rmSync(path.join(OUT, '__spa-fallback.html'), {force: true});

function walk(dir: string): string[] {
  return readdirSync(dir).flatMap(name => {
    const file = path.join(dir, name);
    return statSync(file).isDirectory() ? walk(file) : [file];
  });
}

function move(from: string, to: string) {
  if (from === to) return;
  if (existsSync(to)) throw new Error(`이미 있는 파일입니다: ${to}`);
  mkdirSync(path.dirname(to), {recursive: true});
  renameSync(from, to);
}

for (const file of walk(OUT)) {
  // 사이트맵 리소스 라우트도 클라이언트 내비게이션용 .data를 만드는데 쓰이지 않는다.
  if (file.endsWith('.xml.data')) {
    rmSync(file);
    continue;
  }
  const rel = path.relative(OUT, file);
  let target = rel.split(path.sep).map(decodeURIComponent).join(path.sep);
  if (path.basename(target) === 'index.html' && path.dirname(target) !== '.') {
    target = `${path.dirname(target)}.html`;
  }
  move(file, path.join(OUT, target));
}

// 비어 버린 디렉터리 정리
function prune(dir: string) {
  for (const name of readdirSync(dir)) {
    const sub = path.join(dir, name);
    if (statSync(sub).isDirectory()) prune(sub);
  }
  if (dir !== OUT && readdirSync(dir).length === 0) rmSync(dir, {recursive: true});
}
prune(OUT);

console.log(`[postbuild] ${SRC} → ${OUT}`);

ハイライトした部分がポイントです。順に見ていきます。

index.htmlをフォルダ名.htmlへ移す

ファイル名がindex.htmlで、最上位のフォルダでなければ、そのフォルダ名に.htmlを付けた名前へ移します。win-acme-validation-port/index.htmlはwin-acme-validation-port.htmlに、en/win-acme-validation-port/index.htmlはen/win-acme-validation-port.htmlになります。

サイトのトップページである最上位のindex.htmlはそのままにします。トップページのURLはもともと/なので、変える必要がありません。

パーセントエンコードされたフォルダ名を戻す

このブログはカテゴリやタグの名前に韓国語を使っています。ビルド成果物では、そうしたパスが%EC%9D%B8...のようにパーセントエンコード(URLに使えない文字を%と数字で表した形)のまま残ることがあるので、decodeURIComponentで元の文字に戻します。Cloudflare Pagesはデコードされた名前でファイルを探すためです。

同じ名前があればビルドを止める

move関数は、移動先にすでにファイルがあるとエラーを出します。たとえばa.htmlとa/index.htmlが両方あると、どちらかが上書きされてしまいます。黙って上書きされると、どのページが消えたのか気づきにくいので、いっそビルドを失敗させるようにしました。

空のフォルダを片付ける

ファイルを外に出すと、空のフォルダが残ります。最後のprune関数が空のフォルダを削除します。他のファイルが入っているフォルダ(たとえば英語の記事が入っているen/)は残します。

結果の確認

ビルド前後でファイル構成は次のように変わります。

# react-router build の成果物 (build/client)
win-acme-validation-port/index.html
en/win-acme-validation-port/index.html

# postbuild 実行後 (out)
win-acme-validation-port.html
en/win-acme-validation-port.html

デプロイ後、curl -sIでレスポンスヘッダーだけを確認しました。末尾スラッシュなしのURLは、そのまま200(正常)が返ります。

$ curl -sI https://sncstudio.net/win-acme-validation-port
HTTP/2 200
content-type: text/html; charset=utf-8

逆に末尾に/や.htmlを付けてリクエストすると、Cloudflare Pagesが308(恒久的リダイレクト)で末尾スラッシュなしのURLへ移動させてくれます。狙いどおり、正規URLが/win-acme-validation-portの一つにまとまりました。

$ curl -sI https://sncstudio.net/win-acme-validation-port/
HTTP/2 308
location: /win-acme-validation-port

$ curl -sI https://sncstudio.net/win-acme-validation-port.html
HTTP/2 308
location: /win-acme-validation-port

注意点

  • この方法は、サーバーを使わずHTMLファイルだけをアップロードする静的デプロイで使えます。リクエストごとにサーバーでページを作る方式なら、URLの扱いはそのサーバーの設定で行う必要があります。
  • ページ内のリンクも末尾スラッシュなしのURL(/win-acme-validation-port)で張りましょう。/x/の形のリンクだと、クリックのたびにリダイレクトが1回発生します。
  • サイトマップやcanonicalタグのURLも同じ形になっているか、あわせて確認するとよいです。
  • Cloudflare Pagesと同じ動作をローカルで確かめたいときは、bunx wrangler pages dev outでoutフォルダを起動してみてください。

参照

Footnotes

  1. https://developers.cloudflare.com/pages/configuration/serving-pages/ ↩

2026 © snc studio·プライバシーポリシー