pickuma.
Infrastructure

Astro middleware can't serve 410 Gone under output: 'static' — the catch-all route that can

We deleted 434 articles and needed every URL to answer 410. Astro middleware runs at build time under output:'static', so it returns 404 in production. Here are the two prerender:false routes that actually work on Cloudflare Workers.

7 min read

On 2026-08-17 we deleted 434 articles from this site. Search Console was reporting 633 pages as “Crawled — currently not indexed” against 37 indexed URLs, and the fix was to stop asking Google to crawl interchangeable summary pages. Deleting the MDX files is the easy half. The other half is making every one of those URLs answer 410 Gone instead of 404 Not Found, so crawlers drop them and stop spending budget re-checking.

The obvious place for that logic in Astro is src/middleware.ts. It does not work. Under output: 'static' the middleware runs during astro build, and the URL you point curl at still comes back 404. This is what we shipped instead, on Astro 6.3.1, @astrojs/cloudflare 13.5.0, and wrangler 3.80.0, deployed as a Worker with Static Assets (compatibility_date = "2026-05-01", nodejs_compat).

Two separate reasons middleware can’t return a 410

They compound, and they need different fixes, so it’s worth separating them.

Build-time execution. With output: 'static', every route is prerendered. Astro invokes middleware as part of that render pipeline while the build is running. Returning new Response(null, { status: 410 }) from onRequest changes what the build writes to disk — it does not change what Cloudflare sends at request time, because at request time no JavaScript of yours is involved. Astro still emits dist/server/virtual_astro_middleware.mjs, which is what makes this confusing: the file exists in the deploy artifact, so it looks wired up. It is only reachable if at least one route opts out of prerendering.

The asset router runs before your Worker. Even once a server bundle exists, Cloudflare Workers Static Assets resolves the request against dist/client first. Our wrangler.toml has:

[assets]
directory = "./dist"
binding = "ASSETS"

If a file matches the path, the asset binding serves it and your Worker code never executes. You can invert that with run_worker_first, but paying a Worker invocation on every hit of every live article to catch a fixed list of dead paths is the wrong trade. Leave the default and let the Worker handle only the misses — which is exactly the set you care about.

The routes that do run

Adding one prerender = false route is what creates the request-time path. src/pages/[...gone].astro:

---
export const prerender = false;
import { isGone } from '@/lib/gone';
import { goneResponse, notFoundResponse } from '@/lib/gone-response';

return isGone(Astro.url.pathname) ? goneResponse() : notFoundResponse(Astro.url);
---

That covers the pre-restructure /posts/<slug>/ shape. It does not cover /for-dev/<slug>/, and the reason is the part we got wrong first.

Our articles are served by src/pages/for-[audience]/[...slug].astro, which is prerendered from the content collection via getStaticPaths(). A rest route still matches slugs that aren’t in its static path list. So an unknown /for-dev/<deleted-slug>/ matched the prerendered rest route, resolved to nothing, and 404’d before the root catch-all was ever consulted. Route matching happens before your handler, so there is nothing to patch inside the handler.

The fix is a sibling route, src/pages/for-[audience]/[gone].astro, identical body, also prerender = false. Astro ranks a single-segment dynamic parameter above a rest parameter, so [gone] wins the match against [...slug] — and because it opts out of prerendering, it executes per request. Live articles are unaffected: they’re static files in dist/client and the asset router serves them before SSR routing is consulted at all.

The list itself is a plain Set in src/lib/gone.ts, carrying both URL shapes for each of the 434 removed articles, with the lookup normalising trailing slashes:

export function isGone(pathname: string): boolean {
  const p = pathname.endsWith("/") ? pathname : `${pathname}/`;
  return GONE_PATHS.has(p);
}

Normalise the slash. Cloudflare will hand you both forms and the Set only holds one of them.

Two smaller things that each cost a deploy

Astro.rewrite('/404') throws at runtime when the 404 page is prerendered — you cannot rewrite to a page that has no server handler. The not-found path fetches the built asset and re-wraps it:

const res = await fetch(new URL('/404.html', url));
return new Response(await res.text(), {
  status: 404,
  headers: { 'Content-Type': 'text/html; charset=utf-8' },
});

Separately, @astrojs/cloudflare writes dist/server/wrangler.json with {"binding":"SESSION"} and no id, which wrangler deploy rejects outright. We patch the id back in with scripts/post-build-patch.ts as a build step. Note that the adapter’s generated config is what ships — its assets.directory is ../client, not the ./dist in the repo-root wrangler.toml.

opencode

Terminal coding agent. The mechanical half of a prune like this — turning a classification document into a typed Set, generating both URL shapes per slug, and checking every entry against the sitemap — is exactly the shape of work worth delegating to an agent with repo access.

Open source; you supply your own model API key.

Try opencode

Affiliate link · We earn a commission at no cost to you.

What we can’t tell you yet

We deleted these URLs on 2026-08-17 and we’re writing this on 2026-08-19. We cannot claim a crawl or ranking recovery, because two days is not enough time for one, and we will not dress up the deploy as a result. Google’s documented position is that 404 and 410 are treated nearly identically, with 410 dropped somewhat faster. We picked 410 because re-crawl budget was the specific problem and 410 is the only status that says “do not come back.” You cannot A/B this on a single site, so we won’t pretend we measured it. We append a dated row to docs/traffic-snapshots.json weekly; that series is the only thing that will settle it.

We also did not test the edge-side alternative. Cloudflare Redirect Rules and Bulk Redirects can act on a URL list without it ever entering your deploy artifact. If your gone list is small, stable, and unrelated to your content pipeline, that is probably the better place for it — it survives framework changes and costs no Worker invocation. We kept ours in the Worker because the list is derived from the content collection and changes with it, and because a Set of a few hundred strings is invisible next to the bundle.

The condition that makes all of this unnecessary: if you run output: 'server', Astro middleware executes per request and a five-line onRequest handles the whole problem. The trap is specific to prerendered sites where the middleware file exists, builds cleanly, and silently does nothing.

FAQ

Does 410 actually get URLs dropped faster than 404?
Google documents them as near-equivalent, with 410 processed slightly faster. We chose 410 for the intent signal rather than a measured speedup, and two days after the change we have no data either way. If you need the URLs gone and cannot serve 410 easily, 404 is not a mistake.
Will the catch-all route slow down my live pages?
No. On Cloudflare Workers Static Assets the asset router resolves prerendered pages from dist/client before SSR routing runs, so a request for a real article never reaches the Worker. Only misses do — which is the set you want to handle.
Why two routes instead of one?
Route specificity. A prerendered rest route like for-[audience]/[...slug].astro still matches unknown slugs and 404s before a root catch-all sees them. A single-segment [gone].astro sibling outranks it, so each URL shape needs its own prerender:false handler.

Tools used in this review

Some links above are affiliate links. We may earn a commission if you sign up. See our disclosure for details.

Related reading

See all Infrastructure articles →

Get the best tools, weekly

One email every Friday. No spam, unsubscribe anytime.