Last week I replaced the database behind Notifio. Different provider, same schema, a few minutes where writes had nowhere to go.

The naive version of that is to take the site down. That is wrong for us in a specific way: the site is not only a checkout. It also serves the installer links on the download page and the contact form on the help page, and neither of those needs a row from anywhere. A visitor who cannot download the app they already paid for, because I was moving a table they will never read, has been failed for no reason.

So maintenance mode had to be partial. Here is all of it.

The switch is a constant, not a dashboard

// src/lib/maintenance.ts export const MAINTENANCE = false; export const MAINTENANCE_TITLE = "Down for maintenance"; export const MAINTENANCE_MESSAGE = "Notifio is down for maintenance. Please try again in a little while."; 

Fourteen lines with the comment. Flipping it is a commit and a deploy.

An environment variable would be faster to flip, and I considered it for about a minute. The reason it lost: the copy a user reads during maintenance is part of the switch, and I want those two things in the same file, reviewed together, visible in git log afterwards. A boolean in a dashboard is a piece of production state with no history that I will forget I set. A deploy takes ninety seconds and I am not flipping this twice an hour.

The honest cost is that turning maintenance on requires a working build pipeline. If the thing that is broken is the build, this switch cannot be reached.

The proxy refuses by default

Next 16 renamed the middleware file to proxy.ts. Ours exists only for this feature:

const RETRY_AFTER_SECONDS = 600; const NO_DATABASE_ROUTES = ["/api/download", "/api/contact", "/api/webhooks/resend"]; function needsDatabase(pathname: string): boolean { return !NO_DATABASE_ROUTES.some( (route) => pathname === route || pathname.startsWith(`${route}/`) ); } 

Note the shape of that predicate. It is not a list of routes to block, it is a list of routes to spare, and everything else is assumed to need the database. The difference matters six months from now, when I add an API route and forget this file exists. A blocklist would quietly leave the new route live and let it throw connection errors at users. The allowlist refuses it, which is wrong in the safe direction: a route that is actually fine gets a 503 it did not need, and I notice, and I add it to the array.

Three entries, and each one is a claim I can check. /api/download signs a redirect to a release artifact. /api/contact sends mail. /api/webhooks/resend handles inbound mail forwarding. None of them read a row.

503, and the client that was already ready for it

if (MAINTENANCE && needsDatabase(pathname)) { return NextResponse.json( { error: MAINTENANCE_MESSAGE }, { status: 503, headers: { "Retry-After": String(RETRY_AFTER_SECONDS) } } ); } 

The status code was the only decision in this feature that took real thought, because two different clients read it.

Stripe is one. Webhook endpoints that answer 2xx are marked delivered and never sent again. If maintenance mode had returned 200 with an error body, which is a shape plenty of APIs use, every checkout.session.completed that landed during the window would have been considered handled and the licence row would simply never exist. A non-2xx makes Stripe redeliver with its own backoff, which outlasts any maintenance window I am going to take.

The desktop app is the other, and this is the part I liked. The app validates its licence against /api/validate, and that code already sorts server answers into three buckets rather than two:

if (!res.ok) { if (res.status >= 500 || res.status === 408 || res.status === 429) return null; return { valid: false, autoReply: false }; } 

null means "we do not know", and the caller falls back to the cached entitlement on disk. A false means the server looked the licence up and refused it. That branch exists because of an older bug where a rate limit was being recorded as an invalid licence, which I wrote about separately; the useful accident is that maintenance mode needed no client work at all. A 503 is a 5xx, so every installed copy of the app, including versions shipped months before this file existed, treats a maintenance window as a network blip and keeps monitoring.

What none of those clients do is read the Retry-After header. Our own app ignores it and runs on its own clock: alerts retry on the next poll, the licence check waits five minutes. I set the header to 600 seconds anyway, for well behaved generic clients and because it is the honest answer to the question the status code raises.

The two pages a human sees

The matcher is ["/api/:path*", "/maintenance"], and that second entry is a page rather than an API route:

if (pathname === "/maintenance") { return MAINTENANCE ? NextResponse.next() : NextResponse.redirect(new URL("/", request.url)); } 

The site has no login page, so there is nowhere else sensible for that URL to go when the switch is off. You can try it: notifio.app/maintenance is a 307 to the homepage right now, which is also the quickest way for me to confirm from outside that maintenance is off. The page itself carries robots: { index: false, follow: false }, because a copy of "try again later" in a search index outlives the ten minutes it was true for.

The other page is the pricing page. During maintenance its checkout form is replaced in place by the same two strings from maintenance.ts:

{MAINTENANCE ? ( <div> <p>{MAINTENANCE_TITLE}</p> <p>{MAINTENANCE_MESSAGE}</p> </div> ) : ( /* the real form */ )} 

The proxy would have refused the checkout POST anyway, so this is not what makes it safe. It is what makes it not rude. The failure a user would otherwise get is: fill in an email, click a button, watch it spin, read an error. Removing the button is the same information delivered before they spend anything on it.

What it adds up to

Four files, 141 insertions, and a feature whose entire test is reading two code paths and believing them. During the actual swap the marketing pages, both long form sections, the per site alert pages and the downloads all stayed up, and the only thing a visitor could not do was buy, which is the one thing I would rather defer than half complete.

The part I would tell anyone copying this: write the predicate as an allowlist. Every other decision here is a preference. That one is the difference between a maintenance mode that stays correct as the API grows and one that silently stops covering the routes you added after you wrote it.