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.
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.
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?
Will the catch-all route slow down my live pages?
Why two routes instead of one?
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
2026-08-17
Workers KV's 60-Second Consistency Window: What It Actually Costs You
Workers KV's documented 60-second window is a cache TTL you cannot lower, not a replication delay. Where it bites: cached nulls, uniqueness checks, multi-key updates — and when a Durable Object is the right swap.
2026-08-13
Cache Races in a Publish Pipeline: Why Your IndexNow Ping Misses the Pages You Just Shipped
A deploy API returning 200 does not mean your new URLs are reachable at the edge. Here is how the deploy-then-ping race silently burns IndexNow submissions, and the verification sequence that stops it.
2026-08-12
Object Storage Lifecycle Policies: Cutting Storage and Egress Cost Without Losing Data You Need
How to audit an S3-compatible bucket, write lifecycle rules that actually pay for themselves, and avoid the transition fees, minimum-duration charges, and versioning traps that make bills go up instead of down.
2026-08-19
IndexNow Batch Mode: The lastmod Diff That Took 286 URLs Per Publish Down to 0
Bing Webmaster Tools flags full-sitemap IndexNow submissions as batch mode. Here is the ~40-line lastmod diff that fixes it, the precondition it depends on, and two ways it silently stops working.
2026-08-13
When There Is No API: Driving Chrome With the DevTools Protocol, and When Not To
CDP gives a scheduled agent a real browser when a site ships no API. Here is what it costs in memory and wall clock, the four failure modes that only surface on a cron, and the cases where you should not do it at all.
Get the best tools, weekly
One email every Friday. No spam, unsubscribe anytime.