neerajkumar.me

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:

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.

← All writing