Remix v2 Error Boundaries Code Review
SkillDev toolsReviews Remix v2 error-handling code for the unified ErrorBoundary, isRouteErrorResponse narrowing, throw-vs-return, root boundary scaffolding, and v1 holdovers (CatchBoundary, useCatch). Use when reviewing routes that throw or define ErrorBoundary in a Remix v2 codebase.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Remix v2 Error Boundaries Code Review skill
What this skill tells your AI
The instructions your AI receives, as published by existential-birds/beagle in plugins/beagle-react/skills/remix-v2-error-boundaries-review/SKILL.md and read by ahel’s review.
Targets TypeScript route modules importing from @remix-run/*. No sibling
knowledge skill exists for this topic; the canonical mental model is
summarized inline below and expanded in references/.
v2 Boundary Model (read first)
Remix v2 unified v1's CatchBoundary + ErrorBoundary into a single
ErrorBoundary route-module export. The framework calls it for both
thrown Responses (e.g. throw new Response(...), throw json(...))
and thrown runtime errors (loader/action/render exceptions). Inside
the boundary you read the value with the useRouteError() hook, then
narrow in this order:
isRouteErrorResponse(error)→ it was a thrownResponse; readerror.status,error.statusText,error.data.error instanceof Error→ real runtime error; readerror.message.- else → unknown thrown value; render a generic fallback.
The boundary takes no props. CatchBoundary, useCatch, and the
future.v2_errorBoundary flag are all gone — finding any of them is a
v1 holdover. Errors render the nearest ErrorBoundary and bubble to
the root if none exists; the root boundary remounts the whole document,
so it must render <Meta />, <Links />, and <Scripts />. Only
thrown loader/action results reach the boundary — a return json(...)
with a 4xx status is a successful loader, not an error. Server-side
runtime errors also flow through an optional entry.server.tsx
handleError export (thrown Responses do not).
Quick Reference
| Issue Type | Reference |
|---|---|
Missing route ErrorBoundary, props-on-boundary, narrowing-only instanceof Error, narrowing-only isRouteErrorResponse | references/boundary-shape.md |
Return-instead-of-throw 4xx/5xx, swallowing error.data, throwing strings, missing handleError | references/throw-response.md |
Missing root boundary, root boundary without <Meta />/<Links />/<Scripts />, useLoaderData() in root boundary | references/root-boundary.md |
CatchBoundary export, useCatch import, v2_errorBoundary future flag | references/v1-holdovers.md |
Review Checklist
-
ErrorBoundarydeclaredexport function ErrorBoundary()with no props - Error read via
useRouteError(), notuseCatch()and not a prop - Narrowing checks
isRouteErrorResponse(error)first, thenerror instanceof Error, then fallback -
error.datarendered defensively (typed/narrowed before going into JSX) - 4xx / 5xx in loaders/actions use
throw(notreturn) forResponse/json - Routes that can throw export their own
ErrorBoundary(don't tear down parents for a widget failure) - Root
app/root.tsxexports anErrorBoundarythat renders<Meta />,<Links />, and<Scripts /> - Root boundary uses
useRouteLoaderData("root")(notuseLoaderData()) when reading root data - No
CatchBoundaryexport anywhere; nouseCatchimport; nofuture.v2_errorBoundaryinremix.config.js -
entry.server.tsxexportshandleErrorand pipes runtime errors to an error reporter -
handleErrordoes not assume thrownResponses flow through it (they don't) - Thrown values are
Response/json/Errorinstances — never plain strings or POJOs
Valid Patterns (Do NOT Flag)
These are correct Remix v2 usage and must not be reported as issues:
- Route without
ErrorBoundarythat intentionally inherits from a parent — Boundaries cascade up. A child route may omitErrorBoundaryso the parent (or root) renders the fallback. Only flag if the route handles user-distinct error UX and a parent boundary cannot. throw new Response(...)orthrow json(...)from a loader/action — The canonical way to signal 404/401/403/etc. This is not "using exceptions for control flow"; it is documented v2 contract.- Narrowing only with
isRouteErrorResponse(error)— Acceptable when the route demonstrably only throwsResponses and has no render-time crash risk. Severity is ADVISORY at most; suggest adding aninstanceof Errorbranch for defense-in-depth, do not flag as a bug. ErrorBoundarythat does not calluseRouteError()— Valid when the boundary renders a static "Something went wrong" fallback intentionally (e.g. marketing pages that don't want to surface error detail).- Root
ErrorBoundarycallinguseRouteLoaderData("root")and gettingundefined— Documented defensive pattern (root loader may have thrown). Do not flag theundefinedhandling as "dead code." handleErrorreturning early onrequest.signal.aborted— Documented noise filter, not a swallowed error.handleErrornot handling thrownResponses — By framework contracthandleErroronly fires for runtime errors. The absence ofResponsehandling is correct, not a gap.- Nested
ErrorBoundaryreturning a bare fragment (no<html>/<body>) — Only the root boundary owns the document. Nested boundaries render inside parent layouts and must not include document tags.
Severity guidance
Use these defaults unless the codebase has documented a different scale:
| Pattern | Default severity |
|---|---|
CatchBoundary export or useCatch import in v2 codebase | BLOCKER (build-breaking or dead code) |
Root ErrorBoundary missing <Scripts /> | BLOCKER (dead-end error page) |
ErrorBoundary with ({ error }) v1 prop signature | WARN (silent runtime undefined) |
return json(...) for 4xx instead of throw | WARN (boundary never fires) |
Missing instanceof Error branch on a route with render-crash risk | WARN |
Missing instanceof Error branch on a Response-only route | ADVISORY |
useLoaderData() (vs useRouteLoaderData) in root boundary | WARN (latent loop) |
Missing handleError in entry.server.tsx | ADVISORY (observability gap, not a bug) |
Hard gates (before writing findings)
Run in order. Do not draft user-facing findings until every gate passes for the batch you are about to report.
-
Location evidence — Pass: Each issue lists the repo path to the route module (or
app/root.tsx, orapp/entry.server.tsx) and either a line range or a short verbatim quote from the file you read (not from memory or diff-only guesswork). "The root boundary is wrong" without a path toapp/root.tsxis not reportable. -
Exemption check — Pass: For each issue, you can state in one line why it is not covered by Valid Patterns (Do NOT Flag). In particular: confirm a missing
ErrorBoundaryis not a deliberate cascade to a parent boundary; confirm anisRouteErrorResponse-only narrowing is not on a route that demonstrably only throws Responses (downgrade to ADVISORY in that case). -
v1-vs-v2 marker check — Pass: Before writing the finding, grep the route module (and the repo at large for cross-cutting issues) for:
CatchBoundary,useCatch,v2_errorBoundary,ErrorBoundary({ error,ErrorBoundary({error. If any of these appear, the finding is a v1 holdover (load references/v1-holdovers.md) and must be labeled as such — not as a generic "missing error handling" issue. If none appear, the code is v2-shape and the finding is about v2 correctness. -
Protocol — Pass: You completed the Pre-Report Verification Checklist in review-verification-protocol for this review.
Review Questions
- Does every route that can throw (loader, action, or render) have an
ErrorBoundaryat the right level — local where the recovery UI matters, parent/root where cascade is intentional? - Does each
ErrorBoundarycalluseRouteError()(notuseCatch(), not props) and narrowisRouteErrorResponsefirst? - Are 4xx / 5xx control flows using
throw(notreturn) so the boundary actually fires? - Does
app/root.tsxexport anErrorBoundarywith<Meta />,<Links />, and<Scripts />, and useuseRouteLoaderData("root")defensively? - Are there any v1 markers left (
CatchBoundary,useCatch,v2_errorBoundary,({ error })prop signature)? - Is
handleErrorpresent inentry.server.tsxfor runtime-error observability, with the correct contract (no Response handling)?
Additional Documentation
- Reviewing the
ErrorBoundaryexport shape, hook usage, or narrowing → references/boundary-shape.md - Reviewing thrown
Response/jsonpatterns,handleError, or return-vs-throw → references/throw-response.md - Reviewing
app/root.tsxboundary scaffolding → references/root-boundary.md - Detecting v1 holdovers (
CatchBoundary,useCatch,v2_errorBoundary) → references/v1-holdovers.md - Remix v2 ErrorBoundary docs: https://remix.run/docs/en/main/route/error-boundary
- Remix v2 error handling guide: https://remix.run/docs/en/main/guides/errors
- Remix v2
entry.server/handleErrordocs: https://remix.run/docs/en/main/file-conventions/entry.server
Signals
- GitHub stars
- 81
- Forks
- 8
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
remix-v2-error-boundaries-review- Source
- github.com/existential-birds/beagle