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 the Agility starter, and keep a real 404 status code when you add streaming or Cache Components.
A 404 page is content like any other, so editors should be able to change it in Agility without a deploy. This guide shows how to do that with the App Router, starting from what the Agility Next.js Starter already ships, and then covers the one setup where notFound() alone stops returning a real 404 status: Cache Components.
Coming from the Pages Router? The old recipe was a
pages/404.jsfile withgetStaticProps. The App Router replaces it with thenot-found.tsxfile convention and thenotFound()function fromnext/navigation.
The starter routes every CMS page through app/[...slug]/page.tsx. When a path isn't in the Agility sitemap, getAgilityPage returns no page and the route calls notFound():
// app/[...slug]/page.tsx (starter)
import { notFound } from "next/navigation"
export default async function Page({ params }: PageProps) {
const agilityData = await getAgilityPage({ params })
if (!agilityData.page) notFound()
// ...render the page model
}
notFound() renders the nearest not-found.tsx. The starter's app/[...slug]/not-found.tsx is a hard-coded message. The steps below replace it with a page your editors manage.
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.
not-found.tsxnot-found.tsx is a Server Component and can be async, so it can fetch the /404 page with the same helpers the starter uses everywhere else:
// app/[...slug]/not-found.tsx
import { getAgilityPage } from "lib/cms/getAgilityPage"
import { getPageTemplate } from "components/agility-pages"
export default async function NotFound() {
// Render the /404 page that editors manage in the Agility sitemap
const agilityData = await getAgilityPage({
params: Promise.resolve({ slug: ["404"] }),
})
const AgilityPageTemplate = agilityData.page
? getPageTemplate(agilityData.pageTemplateName || "")
: null
// Hard-coded fallback in case /404 is unpublished or its page model isn't registered
if (!AgilityPageTemplate) {
return (
<section className="relative px-8">
<div className="max-w-2xl mx-auto my-12 prose">
<h1>Page Not Found</h1>
<p>The page you were looking for could not be found.</p>
</div>
</section>
)
}
return <AgilityPageTemplate {...agilityData} />
}
Always keep the hard-coded fallback. If an editor unpublishes /404, you still want something to render rather than an error inside an error.
/404 itself from returning a 200Because /404 is a normal page in the sitemap, the catch-all route would also serve it directly at https://your-site.com/404 with a 200 status, and the starter's generateStaticParams would prerender it as an ordinary page. Leave it out of both:
// app/[...slug]/page.tsx
export async function generateStaticParams() {
// ...the starter's existing sitemap fetch
const paths = Object.values(sitemap)
.filter((node, index) => {
if (node.redirect !== null || node.isFolder === true || index === 0) return false
if (node.path === "/404") return false
return true
})
.map((node) => ({ slug: node.path.split("/").slice(1) }))
return paths
}
export default async function Page({ params }: PageProps) {
const agilityData = await getAgilityPage({ params })
if (!agilityData.page || agilityData.sitemapNode?.path === "/404") notFound()
// ...render
}
notFound() returns a real 404Next.js returns a 404 for non-streamed responses and a 200 for streamed ones, because once a streamed response has sent its headers the status can't change (Next.js not-found reference).
dynamic = "force-static", revalidate = 60), has no loading.tsx, and doesn't enable Cache Components. The page isn't streamed, so notFound() returns a real 404.loading.tsx above the catch-all, or enable Cache Components (cacheComponents: true), the response streams. notFound() still shows the right page, but with a 200 status: a soft 404 that search engines index and monitoring never flags.Either way, check the status code rather than the page content:
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
With Cache Components enabled, every route is partially prerendered: Next sends the static shell, and with it the status line, before your page has finished resolving data. By the time notFound() runs, the 200 is already on the wire.
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 (proxy.ts, formerly middleware.ts). 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" },
})
}
}
// ...your existing preview and redirect handling
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 soft-404 behavior, 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. Give the proxy its own fetch with its own short-lived memoization, 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.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 in next dev, where the check is skipped./404 in the Agility sitemap and publish it.app/[...slug]/not-found.tsx, with a hard-coded fallback.notFound() when a path doesn't resolve (the starter already does), and keep /404 out of generateStaticParams and the normal page render.curl -I. The starter as shipped returns a real 404; if you add streaming or Cache Components, decide 404s in the proxy.