What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Tutorials
Build a CMS-managed 404 page with the Next.js App Router — and fix the soft-404 that Cache Components introduces, where notFound() returns a 200.
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.jsfile plus agetStaticPropsthat reused the[...slug]props, and filtering/404out ofgetStaticPaths. None of that applies here. The App Router replaces it with thenot-found.tsxfile convention and thenotFound()function.
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.
not-found.tsxThe App Router looks for a not-found.tsx file in the route segment. It renders whenever notFound() is called below it.
// 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.
notFound() when a path doesn't resolve// 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
}
/404 from prerenderinggenerateStaticParams is driven by the Agility sitemap, which includes the /404 node. Filter it out so it isn't built as a normal page:
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) }))
}
notFound() returns 200 under Cache ComponentsThis is the part that surprises people, and it is not a bug in your code.
With Cache Components 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.200.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:
// 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:
// 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)
}
true. A CMS blip should degrade you to the old soft-404 behaviour, not 404 your entire site."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./_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.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).Status codes, not page content — the whole failure mode is that the content looks right:
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
/404 in the Agility sitemap and publish it.not-found.tsx, with a hard-coded fallback.notFound() when a path doesn't resolve, and filter /404 out of generateStaticParams.notFound() alone gives you a soft 404. Fail open, allow-list your app-owned paths, and test with curl -I.