
Remix V2 Error Boundaries Review
- 28 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-error-boundaries-review is a Claude Code skill in the AI & Agent Building category.
- remix-v2-error-boundaries-review
- AI & Agent Building
- AI-coding skill
Remix V2 Error Boundaries Review by the numbers
- 28 all-time installs (skills.sh)
- Ranked #9,501 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill remix-v2-error-boundaries-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 28 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Remix v2 Error Boundaries Code 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:
1. isRouteErrorResponse(error) → it was a thrown Response; read error.status, error.statusText, error.data. 2. error instanceof Error → real runtime error; read error.message. 3. 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 `ErrorBoundary` that intentionally inherits from a parent — Boundaries cascade up. A child route may omit
ErrorBoundaryso 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(...)` or `throw 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 throws
Responses 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. - `ErrorBoundary` that does not call `useRouteError()` — 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 `ErrorBoundary` calling `useRouteLoaderData("root")` and getting `undefined` — Documented defensive pattern (root loader may have thrown). Do not flag the
undefinedhandling as "dead code." - `handleError` returning early on `request.signal.aborted` — Documented noise filter, not a swallowed error.
- `handleError` not handling thrown `Response`s — By framework contract
handleErroronly fires for runtime errors. The absence ofResponsehandling is correct, not a gap. - Nested `ErrorBoundary` returning 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.
1. Location evidence — Pass: Each issue lists the repo path to the route module (or app/root.tsx, or app/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 to app/root.tsx is not reportable.
2. 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 ErrorBoundary is not a deliberate cascade to a parent boundary; confirm an isRouteErrorResponse-only narrowing is not on a route that demonstrably only throws Responses (downgrade to ADVISORY in that case).
3. 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.
4. Protocol — Pass: You completed the Pre-Report Verification Checklist in review-verification-protocol for this review.
Review Questions
1. Does every route that can throw (loader, action, or render) have an ErrorBoundary at the right level — local where the recovery UI matters, parent/root where cascade is intentional? 2. Does each ErrorBoundary call useRouteError() (not useCatch(), not props) and narrow isRouteErrorResponse first? 3. Are 4xx / 5xx control flows using throw (not return) so the boundary actually fires? 4. Does app/root.tsx export an ErrorBoundary with <Meta />, <Links />, and <Scripts />, and use useRouteLoaderData("root") defensively? 5. Are there any v1 markers left (CatchBoundary, useCatch, v2_errorBoundary, ({ error }) prop signature)? 6. Is handleError present in entry.server.tsx for 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
ErrorBoundary Shape — Export, Hook, Narrowing
The v2 ErrorBoundary is a route-module export with no props. It reads the thrown value through useRouteError() and must narrow correctly to distinguish thrown Responses from runtime Errors. Most boundary bugs are shape mistakes: props leak in from v1 examples, narrowing covers one case but not the other, or a route that can throw exports nothing at all.
Canonical shape
// app/routes/posts.$slug.tsx
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<section>
<h1>{error.status} {error.statusText}</h1>
<p>{typeof error.data === "string" ? error.data : "Request failed."}</p>
</section>
);
}
if (error instanceof Error) {
return (
<section>
<h1>Something went wrong</h1>
<p>{error.message}</p>
</section>
);
}
return <h1>Unknown error</h1>;
}Three narrowing branches in this order, no props, hook-driven.
Anti-patterns to flag
1. Missing ErrorBoundary on a route that can throw
Pattern
// app/routes/admin.users.$userId.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const user = await db.user.findUnique({ where: { id: params.userId } });
if (!user) throw new Response("Not found", { status: 404 });
return json({ user });
}
export default function UserDetail() {
const { user } = useLoaderData<typeof loader>();
return <UserCard user={user} />;
}
// No ErrorBoundary export.Why bad
The thrown 404 bubbles to the nearest ancestor with a boundary — usually app/root.tsx. A whole-document re-render replaces the admin chrome (nav, sidebar, breadcrumbs) for what should be an inline "user not found" state. If app/root.tsx also lacks a boundary, the user sees Remix's generic "Application Error" page.
Fix
Export a local ErrorBoundary that renders inline recovery UI inside the admin shell:
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) {
return <p>User not found. <Link to="/admin/users">Back to list</Link></p>;
}
if (error instanceof Error) return <p>Failed to load user: {error.message}</p>;
return <p>Unknown error.</p>;
}Exemption: If the route deliberately delegates to a parent boundary that already renders a good fallback, do not flag. Verify the parent boundary exists and handles this route's error shape before exempting.
2. Props on ErrorBoundary (v1 carryover)
Pattern
export function ErrorBoundary({ error }: { error: Error }) {
return <div>{error.message}</div>;
}Why bad
In v2 the boundary receives no props. error is undefined at runtime, so error.message throws inside the boundary — causing an infinite error loop. TypeScript will not catch this because the prop type is unconstrained.
Fix
export function ErrorBoundary() {
const error = useRouteError();
// ...narrow...
}3. Narrowing only with instanceof Error
Pattern
export function ErrorBoundary() {
const error = useRouteError();
if (error instanceof Error) {
return <p>Error: {error.message}</p>;
}
return <p>Unknown error</p>;
}Why bad
Thrown Responses are unwrapped to an internal ErrorResponse, which is not an Error instance. A loader that does throw json({ message: "Forbidden" }, { status: 403 }) hits the "Unknown error" branch and the 403 status, statusText, and payload are all lost. The user sees a generic message; the developer sees no clue what happened.
Fix
Check isRouteErrorResponse(error) first, then instanceof Error:
if (isRouteErrorResponse(error)) {
return <p>{error.status}: {String(error.data)}</p>;
}
if (error instanceof Error) {
return <p>Error: {error.message}</p>;
}
return <p>Unknown error</p>;4. Narrowing only with isRouteErrorResponse
Pattern
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <p>{error.status} {error.statusText}</p>;
}
return <p>Something went wrong</p>;
}Why bad
A real bug — null deref, failed DB call, render-time exception — surfaces as an Error instance, not an ErrorResponse. With no instanceof Error branch the boundary falls through to the generic fallback and the developer loses the message and stack at the UI layer.
Severity rule
- ADVISORY if the route demonstrably only throws
Responses
(loader/action use throw json / throw new Response exclusively and the rendered component is trivial / non-crashing).
- WARN otherwise — most routes can render-crash.
Fix
Add the instanceof Error branch:
if (isRouteErrorResponse(error)) return <p>{error.status} {error.statusText}</p>;
if (error instanceof Error) return <p>Error: {error.message}</p>;
return <p>Something went wrong</p>;5. Calling useLoaderData() inside ErrorBoundary
Pattern
export function ErrorBoundary() {
const data = useLoaderData<typeof loader>();
return <p>Failed for {data.user.name}.</p>;
}Why bad
The boundary may be rendering because the loader threw — in which case useLoaderData() itself throws, causing a second error and a boundary loop. Even when the boundary fires from a render-time crash (not a loader throw), relying on loader data inside the boundary couples the fallback to a code path that just proved it can fail.
Fix
Use useRouteLoaderData("<route-id>") and treat the result as possibly undefined, or render a fallback that does not depend on loader data.
Cross-references
- Throwing patterns (return-vs-throw,
handleError) → throw-response.md - Root boundary specifics (
<Meta />,<Links />,<Scripts />) → root-boundary.md - v1 markers (
CatchBoundary,useCatch) → v1-holdovers.md - Remix v2 docs: https://remix.run/docs/en/main/route/error-boundary
Root ErrorBoundary — Document Scaffolding
The root ErrorBoundary in app/root.tsx is the last-resort boundary for any error that bubbles past every nested boundary (or fires before any nested boundary can mount). Unlike nested boundaries, it re-mounts the entire document — so it owns <html>, <head>, and <body> shells and must render the framework tags itself.
Three common bugs make the root boundary worse than no boundary at all: missing entirely (Remix renders its generic page), present but without <Meta /> / <Links /> / <Scripts /> (white screen of death after first error), or present but reading useLoaderData() (infinite error loop when the root loader is the thing that threw).
Canonical shape
// app/root.tsx
import {
isRouteErrorResponse,
Links,
Meta,
Scripts,
useRouteError,
useRouteLoaderData,
} from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
// Defensive: root loader may have thrown.
const rootData = useRouteLoaderData<typeof loader>("root");
return (
<html lang="en">
<head>
<title>Application error</title>
<Meta />
<Links />
</head>
<body>
<main className="error-shell">
{isRouteErrorResponse(error) ? (
<>
<h1>{error.status} {error.statusText}</h1>
<p>{typeof error.data === "string" ? error.data : "Request failed."}</p>
</>
) : error instanceof Error ? (
<>
<h1>Something went wrong</h1>
<p>{error.message}</p>
</>
) : (
<h1>Unknown error</h1>
)}
{rootData?.user ? <p>Signed in as {rootData.user.email}</p> : null}
</main>
<Scripts />
</body>
</html>
);
}<Meta /> and <Links /> keep critical head tags (charset, viewport, CSS) in the document. <Scripts /> boots the client runtime so the user can navigate away. Missing any of these turns the error page into a dead-end.
Anti-patterns to flag
1. No root ErrorBoundary at all
Pattern
app/root.tsx exports Layout (or just a default App) and loader, but no ErrorBoundary.
Why bad
Any error that escapes every nested boundary hits Remix's built-in fallback — a hard-coded "Application Error" page with no branding, status, or recovery affordance. Production stack traces are stripped by default, so users see a generic message and developers see nothing unless handleError is wired. First-impression error states matter: this is the single most likely page a frustrated user will see.
Fix
Always export ErrorBoundary from app/root.tsx with the canonical shape above.
2. Root boundary missing <Meta />, <Links />, or <Scripts />
Pattern
export function ErrorBoundary() {
const error = useRouteError();
return (
<html>
<body>
<h1>Something went wrong</h1>
<p>{error instanceof Error ? error.message : "Unknown"}</p>
</body>
</html>
);
}Why bad
The root boundary re-mounts the whole document. Without <Scripts /> the client bundle never boots — <Link>, <Form>, navigation, even window.history.back() fall back to native browser behavior. Without <Links /> all stylesheets vanish (unstyled error page). Without <Meta /> the charset, viewport, and per-route meta tags are missing (mobile rendering breaks, SEO tags lost).
The cascade is silent: dev mode with HMR will often hide it because HMR injects scripts; production builds expose the dead shell.
Fix
Render all three:
<head><Meta /><Links /></head>
<body>{ ...error UI... }<Scripts /></body>If your project uses a Layout component that already renders these, the root ErrorBoundary can render <Layout>...</Layout> instead — but verify the Layout component does not itself crash on missing loader data.
3. Root boundary calls useLoaderData()
Pattern
export async function loader() {
const user = await getUserOrThrow(request); // may throw
return json({ user });
}
export function ErrorBoundary() {
const { user } = useLoaderData<typeof loader>(); // throws if loader threw
return <p>Sorry, {user.name}. Something broke.</p>;
}Why bad
If the root loader is the thing that threw — and root loaders often include auth, feature flags, theme — useLoaderData() inside the boundary throws again. Remix sees a second error during boundary render and falls back to its built-in error page. The carefully designed root boundary never appears.
Even when the boundary fires from a child route error (not a root loader error), the boundary becomes brittle: any future change that makes the root loader throw silently breaks the error page.
Fix
Use useRouteLoaderData("root") and handle undefined:
const rootData = useRouteLoaderData<typeof loader>("root");
// rootData may be undefined if root loader threw.
{rootData?.user ? <p>Signed in as {rootData.user.email}</p> : <p>Please sign in.</p>}useRouteLoaderData returns undefined rather than throwing when the data isn't available — making the boundary resilient to loader failure at any level.
4. Root boundary without <html> / <body> wrappers
Pattern
export function ErrorBoundary() {
const error = useRouteError();
return <div className="error">Something went wrong</div>;
}Why bad
The root boundary owns the entire document. Returning a bare <div> means Remix renders that <div> as the document — no <html>, no <head>, no <body>. The browser tolerates this in quirks mode but charset declaration, language attribute, and head tags are gone. The page renders unstyled and accessibility tools report the document is malformed.
Note: nested route ErrorBoundary exports correctly render inside the document (parent layouts still apply), so they should not include <html> / <body>. The wrapper requirement is specific to the root boundary.
Fix
Always wrap root-boundary output in <html><head>...</head><body>...</body></html> with <Meta />, <Links />, and <Scripts />.
Cross-references
- Boundary shape and narrowing → boundary-shape.md
- Throw vs return semantics → throw-response.md
- Remix root route docs: https://remix.run/docs/en/main/file-conventions/root
- Error handling guide: https://remix.run/docs/en/main/guides/errors
Throw vs Return — Responses, Errors, and handleError
In Remix v2, only thrown values reach ErrorBoundary. A return from a loader/action is a successful result — no matter the status code. Choosing throw-vs-return wrong is one of the most common boundary bugs because the code looks correct and types check.
Companion: app/entry.server.tsx may export handleError for server-side error reporting. Its contract has a sharp edge: it does not fire for thrown Responses.
Canonical patterns
// Loader: throw for 4xx/5xx, return for success
export async function loader({ params }: LoaderFunctionArgs) {
invariant(params.invoiceId, "Missing invoiceId");
const invoice = await db.invoice.findUnique({ where: { id: params.invoiceId } });
if (!invoice) {
throw json({ message: `Invoice ${params.invoiceId} not found` }, { status: 404 });
}
return json({ invoice });
}
// Action: throw for auth / validation hard-stops, return for field errors
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request); // throws Response on 401
const form = await request.formData();
const parsed = schema.safeParse(Object.fromEntries(form));
if (!parsed.success) {
return json({ errors: parsed.error.flatten() }, { status: 400 }); // returned — rendered by route
}
await db.invoice.update({ where: { id: parsed.data.id }, data: parsed.data });
return redirect(`/invoices/${parsed.data.id}`);
}// app/entry.server.tsx
export function handleError(
error: unknown,
{ request }: LoaderFunctionArgs | ActionFunctionArgs,
) {
if (request.signal.aborted) return; // ignore client disconnects
if (error instanceof Error) {
reportToSentry(error);
} else {
console.error("Non-Error thrown server-side:", error);
}
}Anti-patterns to flag
1. Returning instead of throwing for 4xx / 5xx
Pattern
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) {
return json({ error: "Not found" }, { status: 404 }); // returned, not thrown
}
return json({ post });
}
export default function Post() {
const data = useLoaderData<typeof loader>();
if ("error" in data) return <p>{data.error}</p>; // ad-hoc error branch
return <article>{data.post.title}</article>;
}Why bad
- The 404 case does not reach
ErrorBoundary. The component now
carries two-shape data and must branch on "error" in data — defeating the v2 model.
- The browser sees a 404 status with a successful HTML body. SEO tools
and crawlers get mixed signals.
- The component types become a discriminated union of "success" and
"error" shapes, which leaks into every consumer.
Fix
if (!post) throw json({ message: "Not found" }, { status: 404 });
return json({ post });The boundary now renders the 404; the component only deals with the success shape.
2. Swallowing error.data (rendering an object as text)
Pattern
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <p>{error.data}</p>; // error.data may be object
}
return null;
}If the loader did throw json({ message: "Forbidden" }, { status: 403 }), then error.data is { message: "Forbidden" }. React renders this as "Objects are not valid as a React child" and the boundary itself crashes.
Why bad
error.data is typed unknown. The shape depends entirely on what the loader threw. Boundaries that assume "data is a string" work for some routes and fail for others.
Fix
Type-narrow before rendering:
if (isRouteErrorResponse(error)) {
const msg =
typeof error.data === "string"
? error.data
: typeof error.data === "object" && error.data && "message" in error.data
? String((error.data as { message: unknown }).message)
: error.statusText;
return <p>{msg}</p>;
}Or define a shared ErrorPayload type and throw json<ErrorPayload>(...).
3. Throwing a non-Response / non-Error value
Pattern
if (!input.email) throw "Email required"; // string
if (!user) throw { code: 401, message: "Unauthorized" }; // POJOWhy bad
useRouteError() returns the value as unknown. Neither isRouteErrorResponse(error) nor error instanceof Error matches, so the boundary falls through to the "Unknown error" branch. Status code information is lost. handleError receives a non-Error value and typically logs "Non-Error thrown server-side" with no stack.
Fix
Always throw a Response (for expected control flow — 4xx) or an Error (for bugs — 5xx):
if (!input.email) throw new Response("Email required", { status: 400 });
if (!user) throw json({ message: "Unauthorized" }, { status: 401 });
if (db.connection.state === "broken") throw new Error("DB connection lost");4. Missing handleError in entry.server.tsx
Pattern
app/entry.server.tsx exports only handleRequest. No handleError, no other server logging in place.
Why bad
In production, Remix logs runtime errors to console.error and shows "Application Error" to the user. Without handleError the errors are never piped to Sentry / Datadog / your reporter — they vanish into stdout (or stderr depending on the host). You only learn about bugs when users report them.
Fix
Export handleError. Filter aborted requests, narrow by type, report:
export function handleError(error: unknown, { request }: LoaderFunctionArgs | ActionFunctionArgs) {
if (request.signal.aborted) return;
if (error instanceof Error) Sentry.captureException(error);
else console.error("Non-Error thrown:", error);
}5. Logging thrown Responses through handleError
Pattern
export function handleError(error: unknown, { request }: LoaderFunctionArgs | ActionFunctionArgs) {
Sentry.captureException(error); // also for Responses?
}Then someone wraps a loader in try { ... } catch (e) { handleError(e, ...); throw e; } to "make sure 404s are tracked."
Why bad
handleError is never called for thrown Responses by the framework — Remix treats them as expected control flow. Manually re-routing every 404/403 through Sentry turns normal user navigation ("user clicks expired link") into pages of error alerts and burns the Sentry quota.
Fix
Trust the framework contract. If you need to track specific 4xx for product reasons (e.g., suspicious 403 spikes), log at the throw site with structured fields — not through handleError:
if (!user) {
metrics.increment("auth.unauthorized", { route: "/admin" });
throw json({ message: "Unauthorized" }, { status: 401 });
}Cross-references
- Boundary shape (narrowing, props, hook) → boundary-shape.md
- Root boundary specifics → root-boundary.md
handleErrordoc: https://remix.run/docs/en/main/file-conventions/entry.serverthrowsemantics: https://remix.run/docs/en/main/guides/errors
v1 Holdovers — CatchBoundary, useCatch, v2_errorBoundary
Remix v1 split error handling across two route-module exports: CatchBoundary for thrown Responses and ErrorBoundary for runtime errors. v2 collapsed them into a single ErrorBoundary. The transition was previewed behind the future.v2_errorBoundary flag in late v1 and the old API was removed in remix@2.0.0.
In a v2 codebase, any of the three markers below is dead code at best, broken behavior at worst. Always flag them and label the finding as a v1 holdover (not a generic error-handling issue) so the fix is unambiguous: delete the v1 API and fold its logic into v2 shape.
How to detect
Grep the route module — and, for v2_errorBoundary, the config — for:
| Marker | Where to look | What it means |
|---|---|---|
export function CatchBoundary / export const CatchBoundary | Any app/routes/**/*.tsx | v1 thrown-Response boundary. Silently ignored in v2. |
useCatch | Any app/**/*.ts(x) import or call site | v1 hook for reading a thrown Response. Does not exist in @remix-run/react v2. |
v2_errorBoundary | remix.config.js, remix.config.ts, vite.config.ts plugin options | v1 future flag, removed in v2.0.0. Triggers a startup warning. |
ThrownResponse, CatchBoundaryComponent types | Any app/**/*.ts(x) | v1 types. Gone in v2. |
unstable_shouldReload | Any app/routes/** | v1 revalidation API (out of scope here but commonly appears in the same files). |
Anti-patterns to flag
1. CatchBoundary export
Pattern
// app/routes/posts.$slug.tsx
import { useCatch } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) throw new Response("Not found", { status: 404 });
return json({ post });
}
export function CatchBoundary() { // v1 export
const caught = useCatch();
return <p>{caught.status} {caught.statusText}</p>;
}
export function ErrorBoundary({ error }: { error: Error }) { // v1 prop signature
return <p>{error.message}</p>;
}Why bad
CatchBoundaryis not a v2 route-module export. Remix silently
ignores it; the function is dead code.
useCatch()is not exported from@remix-run/reactv2. The import
fails at build time ('useCatch' is not exported).
- The thrown 404 now flows to
ErrorBoundary(the v2 contract), but
the ErrorBoundary here uses the v1 prop signature so error is undefined at runtime.
The route appears to handle 404s; in production the boundary crashes or shows nothing.
Fix — collapse to a single v2 `ErrorBoundary`
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <p>{error.status} {error.statusText}</p>;
}
if (error instanceof Error) {
return <p>{error.message}</p>;
}
return <p>Unknown error</p>;
}Delete the CatchBoundary export and the useCatch import entirely.
2. useCatch import (with or without CatchBoundary)
Pattern
import { useCatch, useLoaderData } from "@remix-run/react";Why bad
useCatch does not exist in @remix-run/react@^2. The import line either fails the build (strict TS / no fallback shim) or imports undefined (loose builds), leading to TypeError: useCatch is not a function at runtime.
A useCatch import with no CatchBoundary export is still a v1 holdover — usually the file was partially migrated. The author removed the boundary but forgot to delete the import or a stray hook call.
Fix
Remove the import. Replace any useCatch() call with useRouteError() and narrow with isRouteErrorResponse(error).
3. v2_errorBoundary future flag
Pattern
// remix.config.js
module.exports = {
future: {
v2_errorBoundary: true, // removed in 2.0.0
v2_routeConvention: true,
v2_meta: true,
},
};Why bad
future.v2_errorBoundary was the opt-in flag for the unified boundary during late v1. In remix@2.0.0 the flag — and the old CatchBoundary implementation — were both removed. Leaving the entry in the config produces a startup warning (Unrecognized future flag: v2_errorBoundary) but is otherwise inert. More importantly, its presence signals the config was migrated by a copy-paste from a v1 upgrade guide rather than a v2-native setup; nearby flags (v2_routeConvention, v2_meta, v2_normalizeFormMethod) are likely also stale.
Fix
Delete the entry. Audit the rest of the future block — every v2 flag from the migration era is now the default behavior and should be removed.
4. ThrownResponse / CatchBoundaryComponent type imports
Pattern
import type { ThrownResponse, CatchBoundaryComponent } from "@remix-run/react";
type NotFound = ThrownResponse<404, { message: string }>;Why bad
These types were exported from @remix-run/react in v1 to describe the shape of useCatch() returns and the CatchBoundary component. They are not exported in v2. Type-only imports fail in strict TS builds.
Fix
Replace with the v2 equivalents — there is no direct successor to ThrownResponse because useRouteError() returns unknown and is narrowed via isRouteErrorResponse. Type your thrown payload at the throw site instead:
type ErrorPayload = { message: string };
throw json<ErrorPayload>({ message: "Not found" }, { status: 404 });Inside the boundary, narrow error.data defensively (see throw-response.md anti-pattern #2).
Cross-references
- v2 boundary shape (the migration target) → boundary-shape.md
- Throw /
handleErrorsemantics → throw-response.md - Remix
remix@2.0.0changelog (flag +CatchBoundaryremoval): https://github.com/remix-run/remix/blob/remix@2.0.0/packages/remix-react/CHANGELOG.md - v1 → v2 migration guide: https://remix.run/docs/en/main/start/v2