Remix — and React Router 7's framework mode, which is its direct continuation — has the most monitoring-friendly failure model of the modern React frameworks: loaders run on the server, and a loader that throws produces a real error status on the document request. Where Next.js and Nuxt guides spend half their length on 200s that lie, Remix mostly tells the truth to an HTTP monitor.
Mostly. The exceptions — streaming, SPA mode, clientLoader, and POST-only actions — are exactly where production incidents go to hide, because they're the paths your green dashboard never exercises. This guide covers the honest parts quickly and the exceptions thoroughly. Everything here applies to Remix v2 and React Router 7 framework mode alike; where file conventions differ slightly, the concepts don't.
The honest part: loaders fail loudly
On a document request, the router runs your route's loader on the server before anything streams. A thrown error or a thrown Response renders the nearest ErrorBoundary with a matching error status — a 500 for an unhandled throw, or whatever status you threw. Your database being down turns into an actual 500 on the page URL.
This means one well-chosen page monitor does real work in a Remix app: pick a route whose loader touches your primary data path, and an ordinary HTTP check on it catches dead databases, broken sessions, and throwing middleware. Add a second monitor on a static or lightly-loaded route and the pair becomes diagnostic — dynamic down + static up points at your data layer; both down points at the process, proxy, or DNS.
The health resource route
Resource routes — route modules that export a loader but no component — are Remix's built-in way to serve raw JSON, which makes the standard health endpoint a one-file job:
// app/routes/health.ts (React Router 7: register in app/routes.ts)
export async function loader() {
const checks = { database: "unknown" };
let healthy = true;
try {
await db.execute("SELECT 1");
checks.database = "ok";
} catch {
checks.database = "down";
healthy = false;
}
return Response.json(
{ status: healthy ? "ok" : "degraded", checks },
{ status: healthy ? 200 : 503 }
);
} Hard dependencies gate the status code, checks stay cheap because this runs every few minutes, and a keyword assertion on "status":"ok" keeps a proxy's generic 200 page from impersonating health.
Exception 1: streaming — the 200 leaves before the data does
The moment you return an unresolved promise from a loader and render it with <Suspense>/<Await>, the honest-failure model gets an asterisk. The document starts streaming with a 200 already on the wire; the deferred promise settles afterward. If it rejects, the ErrorBoundary for that section renders — client-side, inside a response that already told your monitor everything was fine.
This is Remix's version of the "200s that lie" problem, and it's scoped precisely to what you deferred. Two defenses:
- Monitor the deferred data's source directly. The promise usually wraps a database query or upstream API call — a monitor on that dependency (often via your health route) catches the cause while the page is still streaming happy 200s around the broken section.
- Keyword-check the settled content. A keyword check asserting on text that only renders when the awaited data arrived catches a persistently-rejecting deferred section. Note the boundary: this works because the server streams the settled HTML into the document; it can't see anything only a browser would render.
A useful discipline: treat every <Await> you add as a small monitoring decision — either its failure is acceptable degradation, or something is watching its data source.
Exception 2: actions — the code path GET monitors never run
Actions run only on POST (and other non-GET methods). Your page monitors are GET requests, which means every mutation in your app lives on a code path your monitoring structurally cannot exercise. A broken write path — exhausted connection pool on writes, misconfigured session storage, a proxy mangling POST bodies — hides behind green checks, and you find out from a user whose form "just spins."
This is the same blind spot as SvelteKit's form actions (where a missing ORIGIN env var 403s every action while GETs stay green). Cover it indirectly, in layers: make your health route check the same dependencies your actions write to (a write-capable database check, a session-store round-trip); monitor any internal API your actions call; and for the flows that pay your bills, run an occasional end-to-end fire drill — automation can verify the dependencies, but only a real submission verifies the path.
Exception 3: SPA mode and clientLoader — rendering moved, so monitoring moves
Two features opt routes out of server rendering entirely:
- SPA mode (
ssr: falsein the config) ships one static shell for the whole app. The shell is always 200 — a CDN serves it whether or not your backend exists. This is the static-site axis for the shell plus the API axis for everything real: monitor your API endpoints, because the page URL now verifies almost nothing. clientLoadermoves a route's data fetching into the browser. Same inversion at per-route scale: the document 200s, then the browser fetches. Whatever theclientLoadercalls is the thing to monitor.
The rule that unifies this whole guide: monitor wherever the data fetching actually runs. Server loaders → the page URL is meaningful. Deferred → the source. Client → the API.
Deployment: the process is still your problem
The default build is a long-running Node server, so the Node.js failure catalog applies wholesale: dead process after a deploy, reverse-proxy misconfiguration, memory leaks that show up as a rising response-time trend first. External monitoring is what confirms your restart policy actually restarted. On serverless presets (Vercel, Cloudflare, Netlify), trade those for cold starts — give timeout thresholds headroom — and per-function limits; the platform guides (Vercel, Cloudflare) cover the specifics. If you use React Router 7's prerendering for some routes, those pages fail on the static/CDN axis instead — monitoring one prerendered page alongside your SSR page keeps the pair diagnostic.
Set it up in ten minutes
- Create a CronAlert account — a typical Remix app needs five to eight monitors, well inside the free plan (25 monitors, 3-minute checks). Keyword checks are Pro at $5/mo.
- Add the health resource route and point a monitor at it.
- Add one loader-heavy page monitor and one static/prerendered page monitor.
- List your
<Await>s andclientLoaders; make sure each one's data source is covered by a monitor. - Route alerts to Slack or email, send a test alert, and fire-drill your most important form once.
Frequently asked questions
What should I monitor in a Remix or React Router app?
A loader-heavy page (loader failures are real 500s), a health resource route, and the backends behind deferred data, clientLoaders, and actions.
How do I add a health endpoint?
A resource route — loader, no component — probing hard dependencies and returning 503 when one is down. Code above.
Why did my page return 200 but render an error?
Streaming: deferred data rejected after the 200 was sent. Monitor the deferred source and keyword-check the settled content.
Can monitoring catch broken actions?
Not directly — actions are POST-only. Check the dependencies actions write to via your health route, and fire-drill critical flows.
Monitor where the fetching runs
Remix's server-first design means basic monitoring works unusually well — a page monitor and a health route catch most of what breaks. Spend your remaining attention on the four exceptions, because they share a trait: they all keep returning 200 while broken. Deferred data, actions, SPA mode, clientLoader — find where each one's data actually fetches, and put the monitor there. Set up CronAlert free and the whole portfolio fits comfortably on the free plan.
Related reading: Next.js and Vercel monitoring, SvelteKit monitoring, Nuxt monitoring, Astro monitoring, and designing health check endpoints.