munchable.app is one Next.js app doing two unrelated jobs. Most of its URLs are content that renders from files in the repo: seven condition guides, several hundred question pages, 38 recipes. A much smaller set needs a database and a signed-in account: sign in, the support desk, the admin pages, the checkout return.
When I need to take the database down on purpose, those two halves should not get the same treatment. Taking the content offline would be pure self-harm: those pages do not touch Postgres and they are the pages search engines send people to. Leaving the account pages up would be worse, because they would fail at whatever depth the query happens to blow up at.
So the maintenance switch has to answer differently depending on what was asked for.
The switch is a constant, not an environment variable
// Typed as boolean, not the literal, so both settings typecheck the same way.
export const MAINTENANCE: boolean = false;
Flipping it is an edit and a deploy. That is deliberate, and it is the same reasoning that put our admin allowlist in a compiled file rather than in an environment variable: a constant that ships with the build cannot be half-set, cannot be set on the wrong environment, and cannot be typo'd into a falsy string at three in the morning.
The type annotation is not noise either. Written as export const MAINTENANCE = false, TypeScript infers the literal type false, and every branch that runs when it is true becomes unreachable code that the compiler is entitled to complain about. Annotating it as boolean means the file typechecks identically in both states, so turning it on is one character and not a tour of the error list.
Three classes of path, three answers
The switch has one job and a router. Paths are sorted into three kinds, and nothing else in the app needs to know the switch exists:
const APP_PREFIXES = ['/admin', '/auth', '/continue', '/get-started', '/support'];
export function maintenanceRoute(pathname: string): MaintenanceRoute {
if (pathname === '/api' || pathname.startsWith('/api/')) return 'api';
const isApp = APP_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
);
return isApp ? 'app' : 'public';
}
An allowlist of the pages that need a backend, and everything else is content. That direction matters. The opposite shape, a list of pages that stay up, means a page added next month is a page that quietly breaks during the next window. This way a new page is public by default and a new page that needs the database gets caught by its prefix.
Each kind gets the answer that is actually true for it:
-
API routes answer
503withRetry-After. That is the one status code that means "this is temporary, come back", and the header says when. Webhooks and cron endpoints are included: they are matched by the proxy only so that maintenance can refuse them too. - App pages are rewritten to the maintenance page, at their own URL.
- Public pages are not touched at all.
CORS still runs on the refused API responses. A 503 the browser will not let our own web app read is a 503 that turns into an opaque network error, which is the one failure mode the whole exercise was meant to avoid.
The rewrite cannot carry a status
Here is the detail that cost me a false start. The obvious implementation is a rewrite with a 503 on it, so the page says "down for maintenance" and the status agrees:
NextResponse.rewrite(url, { status: 503 }); // does not do what you want
Next drops the status of a proxy rewrite and serves the destination page's own. The maintenance page is a normal page that renders fine, so it answers 200. There is no combination of rewrite and status that gives you a 503 body from another route in the app.
The alternatives are a redirect to /maintenance, which replaces the URL the person was trying to reach and loses it, or rendering the maintenance page at the requested URL with a 200. I took the second. During a ten minute window, a human reloading /support should get /support back when it returns, not a URL they now have to navigate away from. The API is where a machine-readable status actually gets read, and the API does send 503.
The maintenance page must not be noindexed
This is the trap that follows from rendering at other pages' URLs.
A page that exists to say "come back later" looks exactly like a page you would slap noindex on. Do that here and you have asked Google to drop /support from the index, because that is the URL the directive was served at. A canonical would be worse: it would announce that /support is a duplicate of /maintenance.
So the maintenance page sets a title and a description and nothing else. No robots directive, no canonical. The pages it stands in for keep their own identity, and the window ends before a crawler's opinion of them has time to change.
With the switch off, the page has nothing to report, so opening it directly sends you to sign in instead. You can watch that happen: munchable.app/maintenance lands on /get-started. While the switch is on, that same /get-started URL is the one rendering the maintenance card, and a content page like the SIBO guide is unaffected.
The part the switch cannot fix
Refusing cron with a 503 is honest, and it is also a dropped run. There is no queue behind those endpoints, so a scheduled job that was due inside the window does not happen late, it does not happen at all.
For the hourly ping that is nothing. For the monthly rewards settlement, which runs once a month at 04:00 UTC on the 8th, it is a job somebody has to run by hand afterwards, and the only thing standing between me and forgetting that is a comment at the top of the file that says so. Writing the switch was an afternoon. Knowing which of my own scheduled jobs cannot survive a ten minute hole was the part worth the afternoon.
Top comments (1)
The dropped monthly settlement is the boundary I'd put in the maintenance test. Simulate its scheduled request getting 503, then reopen the app and run the catch-up twice. Can you show a durable 'period still unsettled' state and make the second run a no-op? Otherwise a comment has to prevent both forgetting the missed run and accidentally settling twice. The public/app/API split makes sense; recording the last completed settlement period separately would make the recovery step just as explicit.