Routing and clean URLs
This page explains how an incoming request path resolves to one of your files: the automatic clean-URL behavior you get out of the box, and how unmatched paths are handled. For changing where paths go, see Redirects & rewrites; for attaching headers to paths, see Custom headers.
How a path resolves
Section titled “How a path resolves”For each request, the canister resolves the path in this order:
- An exact file match. A file in your directory is served directly.
dist/app.jsanswers a request for/app.js. - A routing rule. If no file matches the path verbatim, the rules apply: the
automatic clean-URL rules below, then your own
_redirects, in that order. The first matching rule wins. - The not-found fallback. If nothing matches, the request resolves to the 404 page.
Every one of these outcomes is a certified response.
Clean URLs
Section titled “Clean URLs”You don’t link to .html files. The canister automatically maps each HTML file to a
clean, extension-less URL and treats that as its canonical address, following
Cloudflare’s auto-trailing-slash conventions.
| Your file | Canonical URL | Also handled |
|---|---|---|
dist/index.html | / | /index → / |
dist/about.html | /about | /about/, /about/index → /about |
dist/blog/index.html | /blog/ | /blog, /blog/index → /blog/ |
The “also handled” forms issue a 307 redirect to the canonical URL, so every page
has exactly one address that search engines and visitors land on.
A note on .html URLs
Section titled “A note on .html URLs”Requesting the underlying file directly (e.g. /about.html) currently serves the
file with a 200 rather than redirecting to the canonical /about. A file always
takes precedence over a routing rule at the same path, and /about.html is a file.
This is harmless for most sites; if you need strict canonicalization (one URL only),
avoid linking to the .html form. (The redirect rule for it exists and will activate
automatically if that precedence ever changes.)
Not-found handling
Section titled “Not-found handling”Every request must resolve to a certified response, so there is always a 404 page.
- Default. If you ship nothing special, the canister serves a built-in, certified
404page for any unmatched path. - Custom. Add a file at the root of your directory named
404.html. It’s automatically wired up as the site-wide not-found page (served with a404status). - Client-side routing. A single-page app sends unmatched paths to its shell instead of to a 404 page.
Single-page apps (SPA)
Section titled “Single-page apps (SPA)”If your app does client-side routing (React Router, Vue Router, SvelteKit in SPA
mode, or a hand-rolled history.pushState router), the server has to answer every
URL with the app shell so the router can take over. Add one rule to
_redirects:
/* /index.html 200That’s the whole SPA configuration. 200 makes it a rewrite: the shell’s
contents are served at the requested URL with no visible redirect, so
/dashboard/settings works on a fresh load and on a browser reload, not just on
in-app navigation. Every one of those responses is certified, including the ones at
paths that have no file behind them.
See the spa example
for a complete, runnable project.
What the /* rule changes
Section titled “What the /* rule changes”-
The default 404 page is not added. Your root catch-all takes over the whole path space, so unknown paths serve the shell and the app decides what a bad route looks like.
-
A missing asset returns HTML. A typo’d or stale
/assets/app-old.jsmatches/*too, so it serves the shell withContent-Type: text/htmland a200. This mirrors Netlify and Cloudflare Pages, and it’s the one behavior worth overriding: afetch()for missing JSON gets HTML it can’t parse, and a missing script fails with a confusing MIME-type error instead of a plain 404.Give your build output an honest 404 by scoping a rule to it above the catch-all; rules are matched in file order:
/assets/* /404.html 404/* /index.html 200Real files still win over both rules, so this only affects URLs under
/assets/that don’t exist. It needs a404.htmlin your directory: declaring/*means the built-in default is never added, and a 4xx rule whose target is missing goes inert.
What it doesn’t change
Section titled “What it doesn’t change”- Real files still win. Files are matched before any rule, so you can mix
server-rendered pages into a SPA at no cost: ship
legal.htmland/legalserves it, while/*only catches what nothing else claimed. - Clean URLs still apply. The synthesized rules are matched before
your
_redirects, so/indexstill307s to/and/legal/to/legal.
Two things to get right in the app
Section titled “Two things to get right in the app”- Link assets with absolute paths. Use
/assets/app.js, notassets/app.js. A relative URL resolves against the client route, so at/dashboard/settingsthe browser would request/dashboard/settings/assets/app.js, which the/*rule answers with the HTML shell. - Put shell headers on the file, not the route.
_headerspatterns match the file, and a rewrite reuses its target’s headers, so aCache-Controldeclared for/index.html(or/*.html) is what every client route gets. A block written against/dashboard/*matches no file and does nothing. See patterns match files, not URLs.
See also
Section titled “See also”- Redirects & rewrites: send one path to another, or serve one file’s contents at a different URL.
- Custom headers: attach cache-control, CSP, and other headers to paths.