AIScan fix guide cover: headline 'A 404 that says 404', an Astro logo chip and a broken page changing to a fixed page.
AIScan fix guide cover: headline 'A 404 that says 404', an Astro logo chip and a broken page changing to a fixed page.
AI Readiness

How to return a real 404 with a recoverable body on Astro

Astro returns 200 when a dynamic route finds nothing. Reproduce the soft 404, fix it with Astro.rewrite('/404'), and verify with curl and the AIScan E1 check.

AAIScan Content Team 11 Oct 2026 5 min read
#Astro#404#soft 404#Astro.rewrite#AI readiness

This guide covers E1 · Discoverability.

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

SituationWhat Astro returnsFix
Unmatched URL, src/pages/404.astro exists404 with your pageNone needed
Dynamic route renders "not found" text200Set the status or rewrite
Astro.redirect('/404') in a dynamic route302, then 404 at a different URLUse Astro.rewrite('/404')
Static host ignores 404.htmlHost default, often 200Fix 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})
RouteMiss handlingStatusBody
aPrints "Post not found"200Not-found text, soft 404
bAstro.response.status = 404404Not-found text
cAstro.rewrite('/404')404Your 404 page
dnew Response(null, { status: 404 })404Empty

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