# Implementing a Custom 404 Page with Next.js

> Source: https://agilitycms.com/docs/nextjs/implementing-a-custom-404-page-with-next-js

A 404 page is content like any other — it should be editable in Agility, not hard-coded in your repo. This guide covers the **App Router** approach, and then the one thing that catches almost everyone: with **Cache Components** enabled, `notFound()` alone is no longer enough to return a real `404` status.

> **Coming from the Pages Router?** The old recipe was a `pages/404.js` file plus a `getStaticProps` that reused the `[...slug]` props, and filtering `/404` out of `getStaticPaths`. None of that applies here. The App Router replaces it with the `not-found.tsx` file convention and the `notFound()` function.

## 1. Create the 404 page in Agility

In your sitemap, add a page at `/404`. Give it whatever components you like — a Rich Text Area and a link back home is a good start. Publish the page **and** its components.

Because it lives in the sitemap, editors can change the copy without a deploy. That's the whole point.

## 2. Add `not-found.tsx`

The App Router looks for a `not-found.tsx` file in the route segment. It renders whenever `notFound()` is called below it.

```tsx
// app/[locale]/[...slug]/not-found.tsx
import { getAgilityPage } from "@/lib/cms/getAgilityPage"
import { getPageTemplate } from "@/components/agility-pages"

export default async function NotFound() {
  // render the CMS-managed /404 page
  const agilityData = await getAgilityPage({
    params: Promise.resolve({ slug: ["404"], locale: "en-us" }),
  })

  if (!agilityData.page) {
    return <h1>404 — Page not found</h1>   // fallback if /404 is unpublished
  }

  const Template = getPageTemplate(agilityData.pageTemplateName || "")
  return <Template {...agilityData} />
}
```

Always keep a hard-coded fallback. If an editor unpublishes `/404`, you still want *something* to render rather than an error inside an error.

## 3. Call `notFound()` when a path doesn't resolve

```tsx
// app/[locale]/[...slug]/page.tsx
import { notFound } from "next/navigation"

export default async function Page({ params }) {
  const agilityData = await getAgilityPage({ params })
  if (!agilityData.page) notFound()
  // ...render
}
```

## 4. Exclude `/404` from prerendering

`generateStaticParams` is driven by the Agility sitemap, which includes the `/404` node. Filter it out so it isn't built as a normal page:

```tsx
export async function generateStaticParams() {
  const sitemap = await getSitemapFlat({ locale: "en-us", preview: false })
  return Object.values(sitemap)
    .filter((node) => !node.isFolder && !node.redirect)
    .filter((node) => node.path !== "/404" && node.path !== "/500")
    .map((node) => ({ slug: node.path.split("/").filter(Boolean) }))
}
```

---

## The trap: `notFound()` returns **200** under Cache Components

This is the part that surprises people, and it is **not a bug in your code**.

With [Cache Components](/docs/nextjs/caching-with-next-js-and-agility) enabled, every route is partially prerendered: Next sends the **static shell** — and with it the HTTP status line — before your page has finished resolving data. By the time `notFound()` runs, the `200` is already on the wire.

Next's own documentation is explicit: not-found returns *"200 for streamed responses, 404 for non-streamed"*, and once response headers are sent the status *"cannot be updated."*

The result is a **soft 404**: the right-looking page, the wrong status code. Search engines index it, monitoring never alerts, and link checkers stay green.

Two things that do **not** fix it:

- **`dynamicParams = false`** — unavailable under Cache Components, and it would hard-404 every page published since your last deploy, because a publish webhook clears tags without rebuilding.
- **Rewriting to the not-found page** — the destination's status is not adopted. You get the correct HTML with a `200`.

### The fix: decide 404s before anything renders

The last place the status is still yours to set is the proxy (Next 16's renamed middleware). Validate the path against the published sitemap there, and answer the 404 yourself:

```ts
// proxy.ts
import { NextResponse, type NextRequest } from "next/server"
import { isPublishedPath } from "@/lib/cms/publishedPaths"

const DRAFT_COOKIE = "__prerender_bypass"
let notFoundBody: string | null = null

export async function proxy(request: NextRequest) {
  const isDraft = request.cookies.has(DRAFT_COOKIE)

  // Skip in dev and in draft mode — an editor previewing an unpublished page is
  // exactly the case where the path is legitimately missing from the PUBLISHED sitemap.
  if (process.env.NODE_ENV !== "development" && !isDraft) {
    if (!(await isPublishedPath(request.nextUrl.pathname))) {
      if (notFoundBody === null) {
        const res = await fetch(new URL("/_not-found", request.nextUrl.origin))
        notFoundBody = await res.text()
      }
      return new NextResponse(notFoundBody, {
        status: 404,
        headers: { "Content-Type": "text/html; charset=utf-8" },
      })
    }
  }

  return NextResponse.next()
}
```

And the path check itself:

```ts
// lib/cms/publishedPaths.ts
import agility from "@agility/content-fetch"

// Paths your app serves itself. They are NOT in the Agility sitemap, so they
// must be allowed through explicitly.
const APP_PATHS = new Set(["/", "/sitemap.xml", "/robots.txt", "/llms.txt", "/_not-found"])

let cache: { paths: Set<string>; expires: number } | null = null
const TTL_MS = 60_000

export const isPublishedPath = async (pathname: string): Promise<boolean> => {
  if (APP_PATHS.has(pathname)) return true

  const now = Date.now()
  if (!cache || cache.expires < now) {
    try {
      const client = agility.getApi({
        guid: process.env.AGILITY_GUID!,
        apiKey: process.env.AGILITY_API_FETCH_KEY!,
        isPreview: false,
      })
      client.config.fetchConfig = { cache: "no-store" }
      const sitemap = await client.getSitemapFlat({
        channelName: process.env.AGILITY_SITEMAP || "website",
        languageCode: "en-us",
      })
      cache = { paths: new Set(Object.keys(sitemap)), expires: now + TTL_MS }
    } catch (error) {
      console.error("Sitemap unavailable — failing open.", error)
      if (!cache) return true          // never take the site down over a CMS blip
    }
  }

  return cache!.paths.has(pathname)
}
```

### Four things to get right here

1. **Fail open.** If Agility is unreachable, return `true`. A CMS blip should degrade you to the old soft-404 behaviour, not 404 your entire site.
2. **Don't reuse your `"use cache"` sitemap getter.** `"use cache"` and `cacheTag()` only work inside a render or cache scope. Calling one from the proxy throws `cacheTag() can only be called inside a "use cache" function` — and if your `catch` fails open, the check silently passes everything and the feature does nothing while looking shipped. Give the proxy its own fetch with its own short-lived memoisation, as above.
3. **List `/_not-found` in `APP_PATHS`.** The proxy fetches that page to build its 404 body, and that fetch comes **back through the proxy**. If the path isn't app-owned, the check 404s it, which fetches it again, and the request hangs forever.
4. **Register every hand-written route.** Anything under `app/` that isn't in the Agility sitemap — `/llms.txt`, a custom `/search`, a marketing microsite — must be in `APP_PATHS`, or it will 404 in production while working perfectly in `next dev` (where the check is skipped).

## Verify it

Status codes, not page content — the whole failure mode is that the content looks right:

```bash
curl -I https://your-site.com/a-page-that-does-not-exist   # must be HTTP/2 404
curl -I https://your-site.com/about-us                     # must be HTTP/2 200
```

## Summary

1. Create `/404` in the Agility sitemap and publish it.
2. Render it from `not-found.tsx`, with a hard-coded fallback.
3. Call `notFound()` when a path doesn't resolve, and filter `/404` out of `generateStaticParams`.
4. **If you use Cache Components, decide 404s in the proxy** — `notFound()` alone gives you a soft 404. Fail open, allow-list your app-owned paths, and test with `curl -I`.
