Munchable has a database migration coming that I do not want to run under live traffic. So before the migration, the boring prerequisite: a way to take down the parts of the site that need Postgres and a session, without taking down the parts that do not.
The switch is one exported constant.
// Typed as boolean, not the literal, so both settings typecheck the same way.
export const MAINTENANCE: boolean = false;
export const MAINTENANCE_MESSAGE =
'Munchable is down for maintenance. Please try again in a little while.';
export const MAINTENANCE_RETRY_AFTER_SECONDS = 300;
export const MAINTENANCE_PATH = '/maintenance';
Flipped by hand, committed, deployed. Not an environment variable, for the same reason our admin allowlist is a constant in a compiled file: a value that only one person ever changes, whose change should be reviewable and revertible, does not benefit from living in a dashboard. The annotation : boolean matters more than it looks. Without it the constant narrows to the literal false, and every if (MAINTENANCE) branch becomes unreachable code that TypeScript is entitled to complain about, so the file would only typecheck in one of its two states.
Three kinds of path
The only interesting decision is what "down" means for a given URL, and there are exactly three answers.
const APP_PREFIXES = ['/admin', '/auth', '/continue', '/get-started', '/support'];
export type MaintenanceRoute = 'api' | 'app' | 'public';
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';
}
Note the shape of the prefix test. pathname.startsWith('/admin') alone would also match /administration, and /support would swallow /supported. Neither of those routes exists today, which is exactly why the test for them is worth writing now, while nobody is relying on the answer:
it('matches whole path segments, not prefixes of a word', () => {
for (const path of ['/administration', '/supported', '/apis', '/authors']) {
assert.equal(maintenanceRoute(path), 'public', path);
}
});
public is everything else, and on this site that is most of it. The condition guides, the recipe pages and the few hundred answer pages all render from modules in the repo at build time, so they have no opinion about whether the database is up. You can see the whole set in the sitemap: the answers index and pages like the IBS guide are prerendered, and they stay exactly as they are while everything behind a login is refusing.
Where it is enforced
In the proxy, before anything else happens.
export async function proxy(request: NextRequest) {
if (MAINTENANCE) return maintenanceResponse(request);
const { pathname } = request.nextUrl;
if (pathname.startsWith('/api/webhook')) return NextResponse.next();
const isApi = pathname.startsWith('/api/');
if (isApi && request.method === 'OPTIONS') return corsPreflightResponse(request);
const response = await updateSession(request);
return isApi ? applyCorsHeaders(request, response) : response;
}
This is the file Next 16 calls proxy.ts and earlier versions called middleware.ts. The rename is cosmetic; the matcher and the export are the same job.
The early return is the point. Normally this function refreshes the Supabase session cookie on every matched request, which is a call to Supabase Auth. During maintenance that call never happens, so the switch does not depend on the thing it is protecting being reachable.
The webhook line is worth reading twice. Webhooks pass straight through in normal operation: they are unauthenticated, signature-verified and server to server, so they want neither a session refresh nor CORS. They are matched by the proxy at all only so that maintenance can refuse them.
The 503, and the 200 next to it
function maintenanceResponse(request: NextRequest): NextResponse {
const route = maintenanceRoute(request.nextUrl.pathname);
if (route === 'api') {
if (request.method === 'OPTIONS') return corsPreflightResponse(request);
const response = NextResponse.json(
{ error: 'maintenance', message: MAINTENANCE_MESSAGE },
{ status: 503, headers: { 'Retry-After': String(MAINTENANCE_RETRY_AFTER_SECONDS) } },
);
return applyCorsHeaders(request, response);
}
if (route === 'app') return NextResponse.rewrite(new URL(MAINTENANCE_PATH, request.url));
return NextResponse.next();
}
The API gets the textbook answer: 503 with a Retry-After, and the CORS headers still applied, because the caller is a browser on another origin and a response it cannot read is a response that tells it nothing. Preflights are still answered, for the same reason.
The pages get a rewrite, and a rewrite cannot carry a status. Next serves the destination page's own status for a proxy rewrite and drops the one you set on the rewrite response, so every signed-in page answers 200 with a maintenance notice in the body, at its own URL. I spent a while trying to defeat that before deciding it was correct.
It is correct because the two audiences are different. The 503 exists for machines: the app on someone's phone needs to distinguish "the server is down, back off and retry" from "your request was wrong", and a status code is the only place that distinction can live. A person who clicked a link does not read status codes. They read the page, and the useful thing for them is to see the notice at the URL they asked for, with their address bar unchanged, so that reloading in five minutes does the obvious thing.
The one place it would bite is search, and the fix there is to write nothing:
/**
* No canonical and no robots override, deliberately: this renders at other
* pages' URLs, so either would be read as being about that page. A noindex
* here would ask for /support to be dropped from the index.
*/
export const metadata: Metadata = {
title: 'Down for maintenance',
description: MAINTENANCE_MESSAGE,
};
A noindex on a page that renders at /support's URL is a request to deindex /support. A canonical pointing at /maintenance is worse. Every one of those URLs requires a session anyway, so none of them is a page I want ranked in the first place, and the right move is for this page to make no claim at all.
With the switch off, opening /maintenance directly has nothing to report, so it redirects to sign in. There is no state in which the page is visible and the site is fine.
The client has to agree
The server refusing is not enough when a React app is already in the browser. The sign in hook holds a loading flag, and the first version of this change left it spinning forever behind a button that could not work.
export function useAuth(): AuthState {
const [session, setSession] = useState<Session | null>(null);
const [ready, setReady] = useState(MAINTENANCE);
useEffect(() => {
if (MAINTENANCE) return;
// ... subscribe to Supabase auth state
}, []);
useState(MAINTENANCE) is the whole fix: during maintenance the hook is ready on the first render, with no session, and it never constructs a Supabase client at all. Everyone reads as signed out immediately rather than as "we are still finding out".
That forced a signature change which improved the code in normal operation too. signIn used to return Promise<void>, because once you call the OAuth redirect there is nothing left to say:
/** Resolves with `error` only when sign in is refused before leaving the page
* (maintenance); otherwise the browser is already on its way to the provider. */
signIn: (provider: OAuthProvider, next?: string) => Promise<{ error?: string }>;
Every caller that used to fire and forget now has to decide what to do when the promise resolves with an error, which is how the Get Premium button stopped being able to sit disabled and busy with no explanation:
const startSignIn = async () => {
setBusy(true);
setError(null);
const { error } = await signIn('google', '/?checkout=1');
if (error) {
setBusy(false);
setError(error);
}
};
Three call sites, three places that previously had no error path because the function could not fail. Maintenance mode was the first reason to make it fallible, and the result is better even when the switch is off, because the OAuth redirect itself can fail.
The part that is not automatic
Cron is in the api class, which means scheduled jobs are refused with a 503 along with everything else. Vercel's own documentation is blunt about what that costs: "Vercel will not retry an invocation if a cron job fails." So a run refused during the maintenance window is a run that did not happen, and whether that matters depends entirely on the job.
For the hourly ones it does not. They are idempotent and the next hour picks up the work. For exactly one of them it does, so the note lives at the top of the file with the switch:
// Cron runs refused in the window are not retried. If it covers the monthly
// rewards settlement (04:00 UTC on the 8th), run that by hand afterwards.
That is a comment and not a mechanism, and I am fine with that. A queue that survives maintenance so one monthly job can be replayed is a lot of machinery to own, and the manual alternative is one command, run by the one person who chose the window. Writing down which jobs have no second chance is the part that would actually have been forgotten.
If you want to see what stays up: the recipes and the condition guides are the half of the site that renders from the repo, and the about page explains who is behind it. Everything linked there keeps working while the signed-in half is dark, which is the only reason a one-boolean maintenance mode is worth having rather than a holding page in front of the whole domain.
Top comments (0)