---
title: "How to return a real 404 with a recoverable body on Astro"
slug: fix-soft-404-astro
published: 2026-10-11T13:17:00.876863+00:00
updated: 2026-10-11T13:17:00.876863+00:00
author: "AIScan Content Team"
author_url: https://aiscan.site/about
category: "AI Readiness"
tags: check:E1, platform:astro, Astro, 404, soft 404, Astro.rewrite, AI readiness
description: "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."
url: https://aiscan.site/blog/fix-soft-404-astro
---

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](https://docs.astro.build/en/basics/astro-pages/) 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.

```astro
---
---
<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:

```bash
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:

```astro
---
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](https://docs.astro.build/en/guides/routing/) 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](https://docs.astro.build/en/guides/deploy/cloudflare/) says you must set `not_found_handling` to serve a custom 404 page:

```json
{ "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:**

```bash
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](https://aiscan.site/guides). Related reading: the [Next.js version of this fix](https://aiscan.site/blog/fix-soft-404-nextjs), [why soft 404s mislead agents](https://aiscan.site/blog/soft-404-agent-discovery-problem), and the [Discoverability checks](https://aiscan.site/docs/checks/discoverability). For Astro-specific setup, see [AI readiness setup on Astro](https://aiscan.site/blog/ai-readiness-setup-astro).

