How to Stop Cloudflare Pages Trailing Slash Redirects

On Cloudflare Pages, a static site's /x URL can get redirected to /x/. Here is why it happens (the x/index.html file layout) and how I kept slash-free URLs by flattening the build output into x.html files.

Cloudflare Pages redirects /x to /x/

This blog is a static site hosted on Cloudflare Pages. A static site doesn't build pages on a server for each request. Instead, every page is generated as an HTML file at build time, and those files are served as-is.

I originally built it with Next.js static export, and post URLs had no trailing slash, like https://sncstudio.net/win-acme-validation-port. Search engines had indexed them in that form.

After I migrated the site to React Router 8 and deployed it, visiting an old URL sent me to the same URL with a slash at the end (/win-acme-validation-port/). The page still loaded, so it doesn't look like a big deal, but it is a problem for search:

  • Every indexed post URL now goes through a redirect (an automatic jump to another URL).
  • Search engines treat /x and /x/ as different URLs. It takes time for existing ranking signals to move to the new URL, and the sitemap and canonical (preferred URL) tags no longer match the actual URLs.

So I decided to keep the URLs without a trailing slash, just like before.

The cause: how Cloudflare Pages maps HTML files to URLs

The cause was the file layout of the build output. Cloudflare Pages serves the HTML file whose path matches the requested URL, but it picks the "canonical" URL based on how the file is named1.

Build output fileCanonical URL on Cloudflare PagesIf requested differently
contact.html/contact/contact.html → redirected to /contact
about/index.html/about//about → redirected to /about/

Next.js static export produced files in the first form, like win-acme-validation-port.html. React Router's prerender (which renders pages to HTML at build time) produces the second form, like win-acme-validation-port/index.html. So for the very same page, Cloudflare Pages treated the slash version as canonical and redirected to it.

In other words, the fix isn't a Cloudflare setting. You just need to change the file names.

Flattening x/index.html into x.html after the build

I added a script that runs after the React Router build, walks the output folder, and moves x/index.html to x.html. Since it pulls each file up one level out of its folder, I'll call this "flattening."

I appended the script (scripts/postbuild.ts) to the end of the build command in package.json. This blog runs on bun.

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

Here is the full scripts/postbuild.ts. It copies the React Router output (build/client) into the out folder that gets uploaded to Cloudflare Pages, then renames the files.

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}`);

The highlighted lines are the important part. Let's go through them.

Move index.html to folder-name.html

If a file is named index.html and isn't in the top-level folder, it is moved to the folder's name plus .html. win-acme-validation-port/index.html becomes win-acme-validation-port.html, and en/win-acme-validation-port/index.html becomes en/win-acme-validation-port.html.

The top-level index.html (the home page) stays where it is. The home page URL is / anyway, so there's nothing to change.

Decode percent-encoded folder names

This blog uses Korean characters in category and tag names. In the build output, those paths can end up percent-encoded (characters that can't appear in a URL are written as % plus hex digits, like %EC%9D%B8...). decodeURIComponent turns them back into the original characters, because Cloudflare Pages looks up files by their decoded names.

Stop the build on name collisions

The move function throws an error if a file already exists at the destination. For example, if both a.html and a/index.html exist, one of them would be overwritten. A silent overwrite makes it hard to notice which page went missing, so I'd rather fail the build.

Remove empty folders

After the files are moved out, empty folders are left behind. The prune function at the end deletes them. Folders that still contain other files (like en/, which holds the English posts) are kept.

Checking the result

The file layout changes like this:

# react-router build output (build/client)
win-acme-validation-port/index.html
en/win-acme-validation-port/index.html

# after postbuild (out)
win-acme-validation-port.html
en/win-acme-validation-port.html

After deploying, I checked the response headers with curl -sI. The URL without a trailing slash returns 200 (OK) directly.

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

And if you add a slash or .html to the end, Cloudflare Pages sends a 308 (permanent redirect) back to the slash-free URL. Exactly what I wanted: the canonical URL is now just /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

Things to keep in mind

  • This only works for static deployments where you upload plain HTML files. If a server renders pages per request, URL handling belongs in that server's configuration.
  • Internal links should also use the slash-free form (/win-acme-validation-port). Links in the /x/ form trigger a redirect on every click.
  • It's worth checking that your sitemap and canonical tags use the same form.
  • To reproduce Cloudflare Pages behavior locally, serve the out folder with bunx wrangler pages dev out.

References

Footnotes

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

2026 © snc studio·Privacy Policy