Notifio is a desktop app that watches rental search pages and tells you when a new listing appears. Its marketing site is 38 URLs of Next.js App Router, derived from four TypeScript arrays rather than typed out, and I have audited most of it in public by now.
Here is one I had not looked at. Four of those routes end like this:
if (!site) notFound(); // app/alerts/[site]/page.tsx if (!article) notFound(); // app/guides/[slug]/page.tsx if (!target) notFound(); // app/compare/[target]/page.tsx if (!article) notFound(); // app/for/[audience]/page.tsx Four deliberate throws into a page that does not exist in the repository:
$ find src -name "not-found*" -o -name "error*" -o -name "global-error*" -o -name "loading*" $ Nothing. No not-found.tsx, no error.tsx, no global-error.tsx, no loading.tsx anywhere under src. Every one of those four calls, and every mistyped URL on the domain, is handed to the framework's built-in page. So I went and read what we have been serving.
What the default actually is
$ curl -s -o /dev/null -w "%{http_code} %{size_download}\n" https://notifio.app/nope 404 13615 13,615 bytes. Measured in the loaded DOM:
| /nope | /help | |
|---|---|---|
<a> elements | 0 | 18 |
<script src> | 7 | 9 |
<title> tags | 3 | 1 |
| visible text | 404 / This page could not be found. | the page |
Zero anchors is the one that matters. There is no nav, no footer, no link home, no link to the site hub, nothing. A visitor who follows a stale link, or who types a path with the wrong capital letter, gets two lines of text and a dead end, on a site whose whole job is to get them to a download button.
The three title tags are the framework and our root layout both doing their job and nobody refereeing. In the raw HTML there are two:
<title>404: This page could not be found.</title> <title>Notifio – Rental Listing Alert Monitor</title> After hydration the DOM has three, because the metadata from the root layout is applied again on the client. Browsers use the first one, so the tab says the 404 text, which is at least the honest half of the pair.
The site has one theme, and the 404 has two
The body element on that page carries our own classes:
bg-[#0a0a0a] text-[#fafafa] antialiased font-sans And the computed background is rgb(0, 0, 0). Not our near-black, pure black. In a browser set to a light colour scheme it is white with black text. The reason is one style tag the framework injects:
body{color:#000;background:#fff;margin:0} .next-error-h1{border-right:1px solid rgba(0,0,0,.3)} @media (prefers-color-scheme:dark){ body{color:#fff;background:#000} .next-error-h1{border-right:1px solid rgba(255,255,255,.3)} } That is an unlayered element selector. Our background is a Tailwind v4 utility, which lives in @layer utilities, and an unlayered rule beats every layered one regardless of specificity. I have been bitten by exactly this before, in the other direction, when our own hand-written stylesheet outranked 514 utilities.
So the only page on the site that respects prefers-color-scheme is the one we never wrote. Notifio's site is deliberately one fixed dark theme, and the 404 is the single place where the design follows the operating system instead. The h1 on it renders in system-ui while the body font is still our self-hosted DM Sans, which is the kind of detail that makes a page feel like somebody else's website.
The first branded 404 I ever wrote was for another app, for this exact reason: the default painted the header the wrong colour. Then I shipped a second product and did not do it again.
Who actually lands there
Trailing slashes are not the problem, because the framework already handles them:
/pricing/ 308 -> /pricing /alerts/kamernet/ 308 -> /alerts/kamernet /guides/how-fast-do-rental-listings-go/ 308 -> /guides/how-fast-do-rental-listings-go Case is:
/Pricing 404 /Download 404 /alerts/Kamernet 404 Static paths and dynamic segments are both case sensitive, and a capitalised path is what you get when somebody types a URL from a slide, a printed flyer or a voice note. I wrote about one capital letter turning a path into a 404 on a sister product and the same thing is true here.
And then there is this one:
/for 404 /for/expats 200 /for/students 200 /for/amsterdam 200 Three pages live in a URL space whose index does not exist. That is a deliberate split between two URL spaces, audience pages under /for and guides under /guides, both rendered from one Article type, with one hub page listing both. The hub is at /guides, so the parent of /for/expats is a 404 with no links on it. Anyone who deletes the last segment out of curiosity, which is a thing people do, gets the dead end.
The page we did build
This is the part that annoys me. The site has one carefully made full-screen status page, and it is this:
export const metadata: Metadata = { title: "Down for Maintenance", description: "Notifio is down for maintenance. Please try again in a little while.", robots: { index: false, follow: false }, }; It has a Nav, a Footer, a clock icon in a circle, a heading, a one-paragraph explanation from a shared constant, and a full-width Back to home button. It also has a comment on top saying when you can see it:
// Only reachable while MAINTENANCE is on; src/proxy.ts redirects it home otherwise. That boolean has been true for a few hours in the life of this product. The 404 is reachable by every visitor, every crawler and every stale link, every day, and it got nothing. We built the rare page and skipped the common one, which I think happens because the rare page was a task ("we are taking the database down") and the common one is a default that already technically works.
The missing error boundary, and the honest reason it has not hurt
No error.tsx either, which means an uncaught render error anywhere in the tree lands on the framework's error page: the same typography, the same absence of links, plus a stack trace in development and a bare apology in production.
The reason this has not cost us anything is worth stating rather than glossing over. Nine of our page routes are force-static, and their data is a TypeScript array in the same repository, so a bad field is a build failure rather than a runtime one. Only three pages are force-dynamic: the home page, the download page and the pricing page, because all three resolve a visitor's currency from the request. Those are the three that can throw while somebody is looking, and they are also the three pages where a blank error screen costs real money.
There is no loading.tsx anywhere, and that one is correct by accident: a static page has nothing to stream, so a loading fallback would never render. I would still rather it were absent on purpose.
What I am doing about it
In order:
-
app/not-found.tsxwith the realNavandFooter, a short line of copy, and four links out: the site hub, the guides hub, the help page and the download page. The nav and footer alone take the link count from zero to about twenty. -
app/error.tsxwith areset()button, scoped to the same chrome, so a currency lookup failing on the pricing page does not look like the site is gone. - Either an index page at
/foror a permanent redirect from/forto/guides. The redirect is one line in the config and I am inclined to take it, because the hub that lists both spaces already exists and a second hub would compete with it. - A test that fails if
src/app/not-found.tsxis missing. Our metadata test already walkssrc/appand reads every page file as text withreadFileSync, which is a trick I wrote up for a sister app, so the cheapest version of this is three lines in a suite that is already looking at that directory.
The general lesson I will take from it: a framework default that renders without erroring is the hardest kind of gap to notice, because nothing in the build, the logs or the tests is unhappy. The thing that found this one was counting <a> elements on a page I had never thought to open.
You can check all of it yourself: notifio.app/nope is the before picture while it lasts, notifio.app/maintenance redirects home because the boolean is off, and the pages that do work are the alert site hub, including Kamernet and SpareRoom, with the guides at notifio.app/guides.
