Maintenance mode — implementation plan¶
Refines maintenance-mode.md. See that file for the original idea; this file is the sequenced build plan.
Decisions¶
| Decision | Choice | Why |
|---|---|---|
| Where the gate lives | A middleware export on app/root.tsx |
Root is the single layout every route nests under (login, logout, health, and everything under protected-layout), so gating there covers the whole route tree without touching each route individually |
| How the gate signals "blocked" | throw new Response(...), not return |
Matches the existing house convention (app/routes/asset.tsx, app/routes/project.tsx etc. already throw new Response("Not Found", { status: 404 })) and routes through the existing ErrorBoundary in root.tsx, so no new page/route/component tree is needed |
/health exemption |
An explicit pathname check (new URL(request.url).pathname === "/health") inside the middleware, which calls next() and returns early when true |
/health is a sibling route under the same root layout, so a root-level middleware runs for it too — a pathname check is the only way to exempt one route from a parent-level gate |
| Retry-After header delivery | app/root.tsx grows a headers export that reads errorHeaders off Route.HeadersArgs |
React Router only forwards headers set on a thrown Response to the final HTTP response if the route (or a matched ancestor) declares a headers() function; without it the Retry-After header set on the thrown Response is dropped |
| Message randomness | Picked inside the middleware on every invocation (Math.random()), not cached |
The middleware runs fresh per request (SSR, no client loaders), so this alone satisfies "a different message per reload" with no extra plumbing |
| Env var default | Anything other than the literal string "true" is treated as disabled |
Fail-safe default — an unset or malformed var must never accidentally take the site down |
Prerequisites¶
- Confirm .env.example is the right place to document the new var (it is — every other server-read env var in app/lib/env.server.ts is documented there).
pnpm typecheckrunsreact-router typegen, which will need to re-run afterroot.tsxgainsmiddleware/headersexports so./+types/rootpicks upRoute.MiddlewareFunction/Route.HeadersArgs.
Steps¶
1. Add MAINTENANCE_MODE to the environment layer¶
Goal: app/lib/env.server.ts exposes a MAINTENANCE_MODE: boolean on the frozen env object, following the existing projectsStorage() pattern (a small parsing function, not added to the REQUIRED array since it's optional and defaults off). Document it in .env.example next to the other optional vars, defaulting to false/commented-out.
Acceptance criteria
- [ ] env.MAINTENANCE_MODE is true only when process.env.MAINTENANCE_MODE === "true"; any other value (unset, "false", "1", typo) is false.
- [ ] Not added to REQUIRED — startup must not fail when the var is absent.
- [ ] .env.example documents the var with a one-line comment explaining what it does.
- [ ] pnpm typecheck passes.
2. Create app/lib/maintenance.server.ts¶
Goal: A new server-only module holding everything maintenance-mode-specific, so root.tsx stays thin:
- A const MAINTENANCE_MESSAGES: readonly string[] array of at least five flair-y strings, including the dwarves/granite example from maintenance-mode.md.
- A function pickMaintenanceMessage(): string that returns one entry at random.
- A maintenanceMiddleware: MiddlewareFunction<Response> (same shape as requireSessionMiddleware in app/lib/session.server.ts) that:
- calls next() and returns immediately when env.MAINTENANCE_MODE is false, or when the request pathname is /health;
- otherwise throws a Response with status 503, a Retry-After header, and a JSON body { message: pickMaintenanceMessage() }.
Acceptance criteria
- [ ] Module lives under app/lib/ and has the .server.ts suffix per the server-boundary convention.
- [ ] maintenanceMiddleware never calls Directus, the session store, or any other I/O — it only reads env.MAINTENANCE_MODE and request.url.
- [ ] Thrown Response sets Content-Type: application/json so error.data on the receiving ErrorBoundary is the parsed { message } object, not a raw string.
- [ ] pnpm typecheck passes.
3. Wire the middleware into app/root.tsx¶
Goal: Root exports middleware: Route.MiddlewareFunction[] = [maintenanceMiddleware], imported from the new module. This makes the gate run before every other route's loaders/middleware, including requireSessionMiddleware on protected-layout — so an authenticated visitor is blocked before any auth/token-refresh work happens.
Acceptance criteria
- [ ] import type { Route } from "./+types/root" (already present) resolves Route.MiddlewareFunction after pnpm typecheck regenerates types.
- [ ] No changes to app/routes.ts — /health's exemption is handled entirely inside the middleware from step 2, not by restructuring the route tree.
- [ ] pnpm typecheck passes.
4. Render the maintenance page and propagate Retry-After¶
Goal: Extend the existing ErrorBoundary in app/root.tsx with a branch for isRouteErrorResponse(error) && error.status === 503, rendering a distinct maintenance view (the flair message from error.data.message, no stack trace, no "404"/"Error" heading) instead of falling into the generic branch. Add a headers: Route.HeadersFunction export alongside it that returns args.errorHeaders ?? new Headers() so the Retry-After header set in step 2 reaches the actual HTTP response.
Acceptance criteria
- [ ] Visiting any gated route while MAINTENANCE_MODE=true renders the flair message from the random pick, not the generic "Oops!"/"Error" copy.
- [ ] The HTTP response for a gated route has status 503 and a Retry-After header (verify with curl -i).
- [ ] The existing 404 and unhandled-error branches of ErrorBoundary are unchanged in behaviour.
- [ ] pnpm typecheck and pnpm build pass.
5. Manual verification pass¶
Goal: Confirm end-to-end behaviour across the exemption and non-exemption paths, since there is no test suite.
Acceptance criteria
- [ ] With MAINTENANCE_MODE unset: pnpm dev, spot-check /, /login, and a protected-layout route all load normally.
- [ ] With MAINTENANCE_MODE=true: GET /health still returns 200 {"status":"ok"}.
- [ ] With MAINTENANCE_MODE=true: GET /, GET /login, POST /login (submit the sign-in form), and a protected-layout route (with a valid session cookie) all return 503 with the maintenance page and a Retry-After header.
- [ ] Reloading a gated route two or three times shows at least two different messages from the list.
- [ ] Flip MAINTENANCE_MODE back off (restart the dev server) and confirm normal behaviour returns.