Cloudflare Pages is my go-to for serving static sites. It’s a great experience to push changes to GitHub and have it appear fully built just a couple minutes later. It’s a developer-friendly platform and this site is built with it. Even so, despite their accurate documentation on route handling, the routing behavior was different enough from my assumptions that I ran into sharp edges in various projects:
Unnecessary additional files: Early versions of the Lenticulate website included a UI (designed by 50% Fun) in client-side React combined with the static site framework Astro. I wanted the URLs in the browser to properly reflect the ‘file’ selected in the interface. I created an HTML file to load the UI for every file in the interface. This wasn’t necessary given Pages’ default behavior to serve the root page by default if a path isn’t found.
Unnecesary redirects: After I re-built danallan.net using Cloudflare Pages, Google Search Console reported that several URLs were not indexed because redirects pointed to different canonical locations. The /photos
page would redirect to /photos/
, for instance. I had arbitrarily decided that the canonical URLs would be without a trailing slash and so implemented this site’s internal links with that style. Once I discovered the Pages behavior I adopted trailing slashes to improve site performance1.
- Confusing behavior: I had a frustrating bug in a third project that consisted of static pages but had a single API endpoint handled by a Pages Function. The API existed at route
/api/s3
and was accessible at /api/s3/
but would trigger a 404 response (not even a redirect!) from the server when accessed at /api/s3
. This conflicted with my learned experience that files should be available without slashes. The problem was that the routing was handed off to a server-side framework router that forced trailing slashes.
As a result of these issues I’ve adopted several practices to eliminate these problems in new Cloudflare Pages projects. I’ll get to the suggestions in a moment but just so we’re all on the same page2 here’s a brief summary of Pages routing:
- Cloudflare Pages assumes the site is a Single-Page Application (SPA) by default and serves the root page instead of a 404 page when a path is not found. This is handy for React-based apps where JavaScript running in the browser handles all of the routing and page display but is confusing for static sites.
- URLs to static HTML files are redirected to remove the HTML extension (file
/about.html
is served at URL /about
), which makes files appear folder-like. Actual folders, by contrast, are redirected to include a trailing slash (/contact/index.html
is served at URL /contact/
). More details later. - Cloudflare Pages Functions (server-side code bundled with the site but run in a Cloudflare Worker) use different path routing than static paths.
Suggestions
Fortunately, there’s a few things that have helped me avoid unnecessary redirects and confusing behavior.
ℹ️ NOTE These tips are not intended for SPAs, only static sites or hybrid sites that mix static and server-side (via Pages Functions) content.
- Specify a custom 404 page by creating a
404.html
file in the root of your built site. In Astro, for instance, this would be a /src/pages/404.astro
page. This disables Cloudflare’s assumption that the site is an SPA and will return an HTTP 404 status along with your custom 404 document for URLs that don’t exist. This restores expected 404 behavior but helps you identify broken internal links on your site3.
- Consistently place your static pages in a folder like
name/index.html
instead of name.html
4. Paths will consistently end in a slash this way. If you do the opposite you will run into a scenario where some pages end in slashes and others do not as shown in static routing details section. This isn’t necessary for other non-HTML files like images, /robots.txt
, the sitemap, and so on.
Generate internal site links to include a trailing slash by default. For small sites, you can just have a constant object that you always use to refer to internal URLs:
export const ROUTES = {
home: '/',
about: '/about/',
contact: '/contact/,
};
Or create a utility to append slashes for relative URLs. We can use the URL
class to add the slash without disturbing any search parameters and fragments. In this snippet, href
is the link target and requestUrl
is the current URL of the page:
function addTrailingSlash(href: string, requestURL: URL) {
const url = new URL(href, requestURL);
// only append slashes if the URL is for this site
if (url.origin === requestURL.origin) {
url.pathname += url.pathname.endsWith('/') ? '' : '/';
}
return url.href;
}
Some frameworks allow you to force trailing slashes in a local dev server, like Astro’s trailingSlash:always
configuration option. As you work on your site locally this will help make you aware of when an internal link in your site would have triggered a redirect5.
But be careful! Don’t enable this setting if your project also includes Pages Functions and if the setting also impacts routing for deployed server-side code. This was the reason for the confusing issue described previously: the Pages Function routing code caused the endpoint at /api/s3
to return a 404. Since I did not have control over the client I needed to support calling the endpoint without a trailing slash. Disabling this setting made it work.
Routing details
These suggestions emerge from the way Cloudflare Pages handles routing. If a Function is included with the project then Pages prioritizes routing to the Function first based on the contents of the _routes.json
file. If no Pages Function is executed (or one doesn’t exist at the route) then Pages falls back to static handling. Let’s look at Functions routing first.
Routing to server-side code via Pages Functions
Pages uses the contents of the _routes.json
file to determine which URLs should trigger Function execution and which should fall back to static routing. Here’s an example _routes.json
file from a project that includes an API endpoint that was built using Astro and the Astro Cloudflare adapter:
{
"version": 1,
"include": [
"/api/*"
],
"exclude": [
"/",
"/robots.txt",
"/rss.xml",
"/404",
"/blog/*"
]
}
The excludes
section dictates which paths should be served statically (the Function will not be invoked), and includes
are the paths that will be routed to the Function.
Exclude always take priority over include. So any request to a file inside of /blog/
will always be served statically and will follow the routing for static pages discussed in more detail below. Likewise, the /index.html
root page is served statically. But any URL in /api/
, for instance, gets routed to the Function.
If the request executes your function the routing is then up to your built code.
Routing to static pages and files
If your page is fully static, or if a route has been excluded from handling by a Pages Function, Cloudflare Pages6 will resolve URLs in the following way.
Given the following file structure:
📂 /
┣ 📄 about.html
┣ 📂 contact
┗ 📄 index.html
┣ 📄 blog.html
┗ 📂 blog
┗ 📄 index.html
In short: HTML documents are served without any extension or trailing slash (/about
serves about.html
) and directories are served with a trailing slash (/contact/
serves /contact/index.html
).
URLs in this table link to a playground Pages static website where you can manipulate the URLs or click around to see the redirect and serve behavior described here.
No redirection happens if the corresponding HTML file does not exist. By default, Pages will serve the home page in this case (SPA mode). But if a /404.html
page exists, SPA mode is disabled and Pages returns HTTP code 404 along with the contents of the custom 404 page.
Pages triggers redirects by responding to the request with HTTP code 308 (permanent redirect).