CogniPrep has eight error-handling files in its app/ directory:
app/not-found.tsx
app/error.tsx
app/global-error.tsx
app/games/error.tsx
app/dashboard/error.tsx
app/interview/error.tsx
app/exercises/error.tsx
app/admin/error.tsx
That reads like one pattern repeated. It is three unrelated mechanisms plus one shared body, and the differences between them are not differences of scope. They are differences of what part of the document still exists when they run.
You can see the first one working right now: cogniprep.app/this-page-does-not-exist. Note that the header and the footer are both there, and keep that in mind, because it is the thing the other two cannot do.
The three mechanisms
not-found.tsx renders for an unmatched URL or an explicit notFound() call. It is an ordinary Server Component. Nothing has gone wrong. React is working, the server is working, the layout is intact, and you are rendering a normal page that happens to carry a 404 status. Ours renders the real site header, the real site footer, and three onward links, because a 404 is a navigation problem and the fix for a navigation problem is navigation.
It also carries this:
export const metadata: Metadata = {
robots: { index: false, follow: true },
};
Worth checking what that actually emits. View source on the 404 above and the tag is:
name="robots" content="noindex">
Just noindex. No follow. Next.js omits it because follow is the robots default, so writing follow: true produces no output at all. It is not ignored and it is not a bug; it is a value that agrees with the default. The reason to write it anyway is that index: false, follow: true states the intent in the file: do not index this page, but do keep crawling the links on it. If a future edit sets the whole thing to noindex, nofollow, the diff shows someone changing a decision rather than filling in a blank.
error.tsx is a React error boundary. It must be a Client Component, it catches render errors in its own segment, and it replaces the page below the nearest layout. That last clause is where the design work is.
global-error.tsx catches what the root layout itself throws. Because the thing that broke may be the layout, this file has to supply its own and . There is no site shell left to render into.
Ours is deliberately ugly:
return (
<html>
<body>
<div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
<h2>Something went wrongh2>
<p>We've been notified and are looking into it.p>
<button onClick={() => window.location.reload()}>Try againbutton>
div>
body>
html>
);
Inline styles, no components, no imports beyond React. If your root layout is what crashed, anything you import here is a candidate for crashing too. A fallback that depends on a theme provider is not a fallback.
The file that exists to prevent a fallback
Here is the actual reason app/error.tsx is in the repository, straight out of its own comment:
Without this file, a render error anywhere that is not already covered by a nearer
error.tsxescalates straight toapp/global-error.tsx, which replaces the entire document with an unstyled paragraph and a plain button. That is the right fallback for a broken root layout and the wrong one for a broken marketing page.
This is the bit that catches people out. global-error.tsx is not a last-resort version of error.tsx that you get in extreme cases. It is the only boundary that exists if you have not written a root error.tsx. So a single bad render on one marketing page does not degrade to a styled error page, it blanks the document down to a sans-serif paragraph. The user does not see your site having a problem. They see something that looks like the site is gone.
app/error.tsx sits inside the root layout, so the theme script, the fonts and the providers all survive, and the user gets a real page with a route back. global-error.tsx stays behind it as the thing that handles a broken root layout, which is the only job it is any good at.
If you only take one thing from this post: an App Router project without a root error.tsx has no graceful error state. It has a blank page with a heading.
Why a boundary has to draw its own header
The five error.tsx files share one component, and most of that component's comment block is about layout rather than errors. The reason:
An
error.tsxreplaces the page below the nearest layout, and none of these segments has alayout.tsx. Each page rendersPublicHeaderitself. So when the boundary trips, the header goes down with the page and the user is left on a bare screen with no way back.
That is a consequence of a choice made much earlier, for unrelated reasons. These route segments have no layout.tsx; each page composes its own header and footer. It is a perfectly reasonable structure right up to the moment an error boundary trips, at which point "the page" is the thing that was holding the navigation.
So the fallback renders PublicHeader itself. And then one asymmetry falls out of that:
PublicHeaderis a client component, so a client boundary can render it directly;PublicFooteris deliberately left out. It is a server component whose link tree would be pulled into the client bundle for a screen that exists to say one sentence.
An error.tsx is a Client Component, so everything it renders is client code. The header was already a Client Component and costs nothing extra. The footer is a Server Component with a sizeable link tree, and importing it into a client boundary would ship all of it to every route, permanently, to serve a screen almost nobody reaches. So the error page has a header and no footer, and the 404 page has both, and that difference is not an oversight. It is the server/client boundary showing through.
This is the general shape of the problem and it is easy to miss: the cost of a fallback UI is paid by every user who never sees it. A fallback that imports half your component tree is a bundle tax on the happy path.
One prop that turns the header off
/**
* Off for signed-in surfaces. `PublicHeader` offers "Sign in" and "Sign up",
* which is the wrong thing to show someone who is already signed in and was
* mid-exercise. Turning it off also drops the fixed-header clearance, since
* there is then no fixed header to clear.
*/
withPublicHeader?: boolean;
Two things coupled in one flag, and correctly so. The header is fixed, which means it is out of flow, which means the content below needs top padding to clear it. If there is no header there is no clearance to add:
className={withPublicHeader ? 'pt-24' : 'pt-16'}
If those were two independent props, the combination "no header, 96px of clearance" would be expressible, and sooner or later someone would express it. Deriving the padding from the flag deletes the invalid state rather than documenting it.
What the error page is allowed to tell you
{process.env.NODE_ENV !== 'production' && error.message && (
<p className="font-mono text-xs">{error.message}p>
)}
{error.digest && (
<p className="font-mono text-xs">Reference: {error.digest}p>
)}
The message is development-only. In production Next.js has already replaced a server error's message with a generic string, so printing it there is pointless, and a client error's message has not been sanitised by anyone, so printing it is a small internals leak aimed at a user who cannot act on it.
The digest ships in both. It is the hash Next.js logs alongside the real server-side exception, which makes it the one string a user can paste into a support ticket that lets you find their exact failure. A reference code is more useful to a user than a stack trace precisely because it is not for them.
Reporting, imported late, twice
Both the shared fallback and global-error.tsx load the error reporter inside useEffect rather than importing it at the top:
void import('@sentry/nextjs')
.then((Sentry) => Sentry.captureException(error, { tags: { boundary: scope } }))
.catch(() => {
// Reporting is best-effort; never let it block the fallback UI.
});
A static import at the top of an error boundary pulls the SDK into the initial client bundle of every route that could reach that boundary, which is all of them. Loading it at the moment the error happens costs a user who is already having a bad time one extra request, and costs everyone else nothing.
The empty catch matters as much as the dynamic import. If the reporter fails to load, that is an unhandled rejection inside the component that is supposed to be handling failures. The fallback UI must not depend on telemetry succeeding.
The boundary: scope tag is the cheap win. Each error.tsx passes its own segment name ('root', 'games', 'interview', 'exercises'), so the reported exceptions are already grouped by which part of the app failed before anyone opens a dashboard.
The summary
| file | what broke | what still exists | component type |
|---|---|---|---|
not-found.tsx |
nothing | everything | server |
error.tsx |
a page render | the layout, providers, theme | client |
global-error.tsx |
the root layout | React, and nothing else | client |
They are not three severity levels. They are three different amounts of surviving document, and that dictates what each one is allowed to import. Treating global-error.tsx as a nicer error.tsx gets you a fallback that cannot render. Treating error.tsx as a nicer not-found.tsx gets you a fallback whose own navigation went down with the page.
The 404 is public if you want to compare it against your own: cogniprep.app/this-page-does-not-exist. The site it belongs to is cogniprep.app.
Top comments (1)
What decides which of the three renders, and is that a thing you can test rather than hope for? The third one worries me, because if it stops working, nothing tells you. The app just gets worse right when it's already going badly. Can you make each of the three render when you want, or does the third one only show up once production is already on fire? If you can make it render, do that and watch what happens.