Table of contents
Astro gives you a real 404 for an unmatched URL, and then lets you lose it the moment a dynamic route says "not found" without saying 404. The page looks right, the status line says 200, and agents that read only the status (AIScan check E1) treat every made-up URL on your domain as valid. This guide reproduces the failure on Astro 7.3.8, shows four ways a route can answer, and gives the curl that proves the fix. Tested on 11 October 2026 with @astrojs/node 11.1.7.
Quick summary
| Situation | What Astro returns | Fix |
|---|---|---|
Unmatched URL, src/pages/404.astro exists | 404 with your page | None needed |
| Dynamic route renders "not found" text | 200 | Set the status or rewrite |
Astro.redirect('/404') in a dynamic route | 302, then 404 at a different URL | Use Astro.rewrite('/404') |
Static host ignores 404.html | Host default, often 200 | Fix the host setting |
Fastest route: run npx aiscan-cli yoursite.com and read E1, Correct 404 handling. The by-hand check is in Step 4.
Why Astro returns 200
Astro renders whatever your page component produces and sends 200 unless you change it. A missing database row or an empty getEntry() result is not an error to Astro, so a template that prints "Post not found" is a successful response. Astro's pages guide covers the file that handles unmatched URLs: a 404.astro or 404.md in src/pages builds to 404.html, and "most deploy services will find and use it". It says nothing about routes that match the pattern and then find no data. That gap is where soft 404s come from.
Step 1: Add a 404 page agents can use
Create src/pages/404.astro. E1 grades the body as well as the status, so an empty "Not Found" loses points even with a correct code. Name the site and link somewhere useful.
---
---
<html lang="en">
<head><title>Page not found | Example</title></head>
<body>
<h1>Page not found</h1>
<p>This page is not on example.com. Try these instead:</p>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/blog/">Blog</a></li>
<li><a href="/sitemap-index.xml">Sitemap</a></li>
</ul>
</body>
</html>
Step 2: Reproduce the soft 404
I built a server-rendered project (output: 'server' with the Node adapter) with the same route written four ways. Each one looks up a slug in a list and handles a miss differently:
curl -s -o /dev/null -w '%{http_code}\n' localhost:4321/a/zzz # prints "not found" text only
curl -s -o /dev/null -w '%{http_code}\n' localhost:4321/b/zzz # Astro.response.status = 404
curl -s -o /dev/null -w '%{http_code}\n' localhost:4321/c/zzz # return Astro.rewrite('/404')
curl -s -o /dev/null -w '%{http_code}\n' localhost:4321/d/zzz # return new Response(null, {status: 404})
| Route | Miss handling | Status | Body |
|---|---|---|---|
| a | Prints "Post not found" | 200 | Not-found text, soft 404 |
| b | Astro.response.status = 404 | 404 | Not-found text |
| c | Astro.rewrite('/404') | 404 | Your 404 page |
| d | new Response(null, { status: 404 }) | 404 | Empty |
Route a is the failure. Routes b to d all fix the status; only route c also serves the recovery links from Step 1, so it is the one that passes both halves of E1.
Step 3: Fix the dynamic route
Edit the page, for example src/pages/blog/[slug].astro, and return the rewrite in the frontmatter when nothing matches:
---
const { slug } = Astro.params;
const post = posts.find((p) => p.slug === slug);
if (!post) return Astro.rewrite('/404');
---
Astro.rewrite() keeps the visitor on the original URL and renders the other page's content. In my test the rewrite to /404 came back as a true 404 with the 404.astro body. If your route does more work before the lookup, put the check first so you do not render half a page.
The trap in Astro's own routing guide
The routing guide shows a catch-all route that ends a miss with return Astro.redirect("/404"). I tested that exact pattern. The missing URL answers 302 with location: /404, and a client that follows it ends on a 404 at a different address. The status is 404 in the end, but the original URL never said so, and crawlers that do not follow redirects record a 302. Use Astro.rewrite('/404') instead.
Static builds and hosts
A fully static site has no dynamic miss to handle, but the host still has to serve 404.html with a 404 status. Both the dev server and astro preview did that for me, so a 200 on your live site comes from the host. On Cloudflare Workers, Astro's deploy guide says you must set not_found_handling to serve a custom 404 page:
{ "assets": { "directory": "./dist", "not_found_handling": "404-page" } }
Cloudflare's single-page-application value serves /index.html for unknown navigation requests, which is a soft 404 for every bad URL.
Step 4: Verify the fix
With AIScan (default): run npx aiscan-cli yoursite.com. In the report, open Discoverability and find E1, Correct 404 handling. It should read pass, with the 404 body counted.
By hand with curl:
curl -s -o /dev/null -w '%{http_code}\n' https://yoursite.com/this-page-does-not-exist-9f3a
curl -s https://yoursite.com/this-page-does-not-exist-9f3a | grep -c '<a '
Expected: 404, then a link count of at least 2. Test one URL that matches a dynamic pattern (such as /blog/zzz) as well as one that matches nothing, because only the first exercises the route you just fixed. Also add -I and confirm there is no location: header.
Edge cases
- Prerendered routes with
getStaticPaths()only build the paths you return. Other URLs are unmatched and get the plain 404 page. - Middleware that rewrites or catches errors can override a status. Test through the live domain, not only localhost.
- A CDN rule that swaps 404s for a custom 200 page hides a correct origin.
Next step
Run the scan, check E1 on your own domain, and read the rest of the series in AIScan's guides. Related reading: the Next.js version of this fix, why soft 404s mislead agents, and the Discoverability checks. For Astro-specific setup, see AI readiness setup on Astro.
Frequently asked questions
Why does my Astro not-found page return 200 instead of 404?
Because Astro sends 200 unless you change it. A dynamic route that prints "not found" text is a successful response. Return Astro.rewrite('/404') when the lookup fails, or set Astro.response.status = 404.
curl returns 404 for /nowhere but 200 for /blog/zzz. Is that a bug?
No. /nowhere matches no route, so Astro serves 404.astro with a 404. /blog/zzz matches [slug].astro, which rendered successfully. Handle the miss inside that route.
Astro.redirect('/404') gives me a 302. Why?
Redirect sends a 302 with a location header, and the 404 only appears after the client follows it. Use Astro.rewrite('/404'), which keeps the URL and returns 404 with your 404 page.
What does E1 need in the 404 body?
A body that names the site and links somewhere useful, such as the homepage, blog or sitemap. An empty page fails even with a correct 404 status.
My live site returns 200 for missing pages but npm run preview returns 404. What changed?
The host. Preview serves 404.html correctly, so check the host's not-found setting. On Cloudflare Workers set assets.not_found_handling to 404-page, not single-page-application.
Does return new Response(null, { status: 404 }) pass E1?
The status passes, but the body is empty, so E1 still marks it a dead end. Rewrite to your 404 page to give agents recovery links.
My Astro.rewrite('/404') still returns 200. What should I check?
Confirm src/pages/404.astro exists and that you return the rewrite (return Astro.rewrite('/404')). Then run curl against the live domain, since a CDN rule can replace the origin response.
Do I need a paid tool to check this?
No. The two curl commands in Step 4 are enough. AIScan runs the same E1 check for free with npx aiscan-cli.
Related guides
What a Failing AIScan Check Really Says: 2,961 Fail Lines Audited
A failing check on a scan report is supposed to be an instruction. The site owner reads the line, knows what the scanner asked for, knows what came back, and knows what to change. We audited whether…
How to return a real 404 with a recoverable body on Wix
Wix gets the status code right and leaves the page empty. Request a URL that does not exist on a Wix site and you get a genuine 404, but the body is a scriptbuilt shell with no text and no links in…
Which Agent-Readiness Checks Travel Together: 739 Sites, Three Bundles
Fix one agentreadiness check and a second one often moves with it. Fix another and nothing else changes. A scan report lists every check as its own line, so the reader cannot see which lines are…
How to return a real 404 with a recoverable body on Docusaurus
Docusaurus builds a real 404.html for you, and then the place you test it hides it. Run npm start, type a madeup URL and you get a friendly page with a 200 status. The production build behaves…
