Shipping an Astro site to Cloudflare: the parts tutorials skip
Every “build a portfolio with Astro” post ends at the same place: npm run build, drag the folder somewhere, done. That part genuinely is easy.
What none of them cover is the hour after that — when the site is live and working, and quietly wrong in ways nothing warns you about. No build error, no broken page. Just canonical URLs pointing at redirects and a sitemap that disagrees with every page in it.
Here is what actually bit me.
First: it costs nothing
I assumed I was buying hosting. I wasn’t. The Cloudflare Workers pricing page says it plainly:
Requests to static assets are free and unlimited.
The Free plan’s headline limits — 100,000 requests per day, 10ms CPU per
invocation — apply to Worker invocations. A static site has no main entry
point in its config, so no Worker ever runs. Every request is a static asset,
and static assets are not billed.
A million visitors would cost the same as ten. The only recurring line item is the domain registration.
That reframing matters, because it removes the usual reason people reach for a subdomain on someone else’s platform.
Workers or Pages? Cloudflare has quietly changed the answer
Search for this and you will find Pages. The dashboard will send you to Workers.
They both host static sites, and both read _headers and _redirects from
your output directory. But the setup screens differ in a way that matters: the
Workers flow asks for a deploy command, prefilled with
npx wrangler deploy
and that command fails on a repo that does not have a wrangler.jsonc. Nothing
tells the deploy step which directory to serve. The build succeeds, the deploy
errors, and the message does not obviously point at a missing config file.
The whole file is this:
{
"name": "portfolio",
"compatibility_date": "2026-09-07",
"assets": {
"directory": "./dist",
"html_handling": "drop-trailing-slash",
"not_found_handling": "404-page"
}
}
No main key. That absence is the important part — it is what makes this a
static deployment with no server code and no invocation costs.
not_found_handling: "404-page" serves your 404.html with a real 404 status
rather than a 200, which matters more than it sounds: a soft 404 gets your
error page indexed.
I will come back to html_handling, because it is the interesting one.
The trailing slash problem nobody mentions
This is the one that actually cost me time, and it produces no error anywhere.
Astro’s default build.format is directory. It emits:
dist/work/index.html
and its canonical tags and sitemap use the trailing-slash form:
<link rel="canonical" href="https://example.com/work/">
Most static hosts, including Cloudflare and Vercel, serve clean URLs and
normalise away from the trailing slash. So /work/ issues a 308 to
/work.
Line those two facts up:
- Your sitemap tells Google to crawl
/work/ - Your host redirects
/work/to/work - Your canonical tag on that page says
/work/
Every indexed URL now points at a redirect, and the canonical disagrees with the URL that actually serves. Nothing is broken enough to notice by clicking around. You find out from a crawl report weeks later, if at all.
The fix is to make Astro emit what the host serves:
// astro.config.mjs
export default defineConfig({
site: "https://example.com",
trailingSlash: "never",
build: { format: "file" },
});
Now the build produces dist/work.html, the host serves it at /work, and the
canonical, the sitemap and the served URL are the same string.
Pair it with html_handling: "drop-trailing-slash" in the wrangler config so
/work/ and /work.html both 308 to the canonical form. Verify with curl
rather than a browser — browsers hide redirects:
curl -sI https://example.com/work/ | head -1 # 308
curl -sI https://example.com/work.html | head -1 # 308
build.format: "file" then breaks your canonicals a second time
Here is the trap inside the fix.
With format: "file", Astro.url.pathname carries a .html suffix at build
time. If your layout does the obvious thing:
const canonical = new URL(Astro.url.pathname, Astro.site);
you now emit:
<link rel="canonical" href="https://example.com/about.html">
A URL that is not in your own sitemap, and not the one the host serves. You have swapped one mismatch for a worse one.
It fails silently in a second place too. Any nav that highlights the current page like this:
aria-current={path === item.href ? "page" : undefined}
stops matching, because "/about.html" === "/about" is false. No error. The
highlight just never appears, and if you are not looking for it you will not
notice.
One helper fixes both:
// src/lib/url.ts
export const cleanPath = (pathname: string): string => {
const stripped = pathname.replace(/\.html$/, "").replace(/\/index$/, "");
return stripped === "" ? "/" : stripped;
};
Run every path through it — canonicals, og:url, nav state.
The check worth automating: after any routing change, assert that the set of canonicals equals the set of sitemap URLs. Mine now does, and that one assertion would have caught both bugs.
“Discovered pages: 0” is not a bug
Last one, because it looks like a failure and is not.
Submit sitemap-index.xml to Search Console and the row may sit at 0
discovered pages indefinitely. A sitemap index contains no page URLs — only
pointers to child sitemaps. Google attributes discovered pages to the
child, so the index row is legitimately zero forever.
Submit sitemap-0.xml as well. That row is where the count appears.
While you are there, one adjacent mistake: the “Add a new sitemap” field is prefixed with your domain, and you type only the path. Leave it empty and you submit your homepage as a sitemap. Google reads it, finds HTML, and reports “Sitemap is HTML” — an error that reads like your sitemap is malformed when it is fine and simply was not the thing submitted.
What I would tell myself before starting
Not “use Astro” or “use Cloudflare” — both were the right call and neither was the hard part.
It is that a static site has a URL contract: the path your host serves, the path in your canonical tag, and the path in your sitemap all have to be the same string. Astro’s defaults and your host’s defaults will not agree out of the box, and nothing in either toolchain will tell you.
Decide the form you want, configure both ends to produce it, and verify with
curl -sI rather than by clicking. Ten minutes, and it is the difference
between a site that is indexed and one that merely works.