From 9ed62eb61a78dde518188f4f3f5fc3c187e4e03b Mon Sep 17 00:00:00 2001 From: Brooks Lybrand Date: Fri, 7 Nov 2025 15:31:24 -0600 Subject: [PATCH 1/3] Enhance resource routes documentation with detailed error handling guidelines for internal and external access methods. --- docs/how-to/resource-routes.md | 103 +++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/docs/how-to/resource-routes.md b/docs/how-to/resource-routes.md index d3ccb1a52f..8bd8f2a790 100644 --- a/docs/how-to/resource-routes.md +++ b/docs/how-to/resource-routes.md @@ -65,3 +65,106 @@ export function action(_: Route.ActionArgs) { }); } ``` + +## Error Handling + +Resource routes can be accessed in two ways, and the error handling approach depends on how they're accessed: + +### Internal access (via fetchers) + +If your resource route is accessed internally via [`useFetcher`](../api/hooks/use-fetcher) (or might be accessed both ways), use `data()` instead of `Response` objects. This allows the route to be encoded into single-fetch responses and supports streaming: + +```tsx filename=api/data.ts +import type { Route } from "./+types/data"; +import { data } from "react-router"; + +export async function loader({ params }: Route.LoaderArgs) { + const record = await getRecord(params.id); + + if (!record) { + // Return 4xx errors - these are expected and don't trigger ErrorBoundary + return data( + { error: "Record not found" }, + { status: 404 }, + ); + } + + return data(record); +} + +export async function action({ + request, +}: Route.ActionArgs) { + const formData = await request.formData(); + const input = formData.get("input"); + + if (!input) { + // Return 4xx for validation errors + return data( + { error: "Input required" }, + { status: 400 }, + ); + } + + try { + const result = await processData(input); + return data(result); + } catch (error) { + // Throw 5xx errors - these trigger ErrorBoundary for the fetcher + throw data({ error: "" }, { status: 500 }); + } +} +``` + +- **`return data()`**: For normal responses and expected errors (4xx). The data is available in `fetcher.data` and won't trigger an error boundary. +- **`throw data()`**: For fatal errors (5xx) that should trigger the error boundary. Use [`isRouteErrorResponse`](../api/utils/isRouteErrorResponse) in your [Error Boundary](./error-boundary) to handle these. + +### External-only access (REST API) + +If your resource route is only accessed externally (via `fetch`, `cURL`, direct browser navigation, etc.), treat it as a standard REST API endpoint. Always return `Response` objects with appropriate status codes: + +```tsx filename=api/users.ts +import type { Route } from "./+types/users"; + +export async function loader({ params }: Route.LoaderArgs) { + const user = await getUser(params.id); + + if (!user) { + // Return 404 for not found + return Response.json( + { error: "User not found" }, + { status: 404 }, + ); + } + + return Response.json(user); +} + +export async function action({ + request, +}: Route.ActionArgs) { + const formData = await request.formData(); + const email = formData.get("email"); + + if (!email || !isValidEmail(email)) { + // Return 400 for validation errors + return Response.json( + { error: "Invalid email" }, + { status: 400 }, + ); + } + + try { + const user = await createUser(email); + return Response.json(user, { status: 201 }); + } catch (error) { + // Unexpected errors will be caught and wrapped in a 500 response + // In production, error details are sanitized for security + throw error; + } +} +``` + +When accessed externally, resource routes don't render error boundaries (there's no UI to render). Any thrown `Error` instances will be automatically wrapped in a 500 response and sanitized in production. + +When a resource route using `data()` is accessed externally, React Router automatically converts the `data()` response to a `Response` object, allowing you to write flexible resource routes that work both ways. From 82cef0155018ff5ff4f9097627420c79af7bd5b0 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 10 Nov 2025 11:56:31 -0500 Subject: [PATCH 2/3] Updates --- docs/how-to/resource-routes.md | 132 +++++++++++---------------------- 1 file changed, 45 insertions(+), 87 deletions(-) diff --git a/docs/how-to/resource-routes.md b/docs/how-to/resource-routes.md index 8bd8f2a790..3dd060eaa8 100644 --- a/docs/how-to/resource-routes.md +++ b/docs/how-to/resource-routes.md @@ -66,105 +66,63 @@ export function action(_: Route.ActionArgs) { } ``` -## Error Handling - -Resource routes can be accessed in two ways, and the error handling approach depends on how they're accessed: - -### Internal access (via fetchers) +## Return Types -If your resource route is accessed internally via [`useFetcher`](../api/hooks/use-fetcher) (or might be accessed both ways), use `data()` instead of `Response` objects. This allows the route to be encoded into single-fetch responses and supports streaming: +Resource Routes are flexible when it comes to the return type - you can return [`Response`][Response] instances or [`data()`][data] objects. A good general rule of thumb when deciding which type to use is: -```tsx filename=api/data.ts -import type { Route } from "./+types/data"; -import { data } from "react-router"; +- If you're using resource routes intended for external consumption, return `Response` instances + - Keeps the resulting response encoding explicit in your code rather than having to wonder how React Router might convert `data() -> Response` under the hood +- If you're accessing resource routes from [fetchers][fetcher] or [`
`][form] submissions, return `data()` + - Keeps things consistent with the loaders/actions in your UI routes + - Allows you to stream promises down to your UI through `data()`/[`Await`][await] -export async function loader({ params }: Route.LoaderArgs) { - const record = await getRecord(params.id); - - if (!record) { - // Return 4xx errors - these are expected and don't trigger ErrorBoundary - return data( - { error: "Record not found" }, - { status: 404 }, - ); - } +While we don't recommend it, you _can_ return plain JS objects from a resource route and they will be converted into an appropriate `Response` based on to the HTTP Request (a single-fetch response for `.data` requests or a JSON response for other external HTTP requests). We don't prefer this pattern because `new Response()`/`data()` calls keep the code explicit for API-only/non-UI routes. - return data(record); -} +## Error Handling -export async function action({ - request, -}: Route.ActionArgs) { - const formData = await request.formData(); - const input = formData.get("input"); - - if (!input) { - // Return 4xx for validation errors - return data( - { error: "Input required" }, - { status: 400 }, - ); - } +Throwing an `Error` from Resource route (or anything other than a `Response`/`data()`) will trigger [`handleError`][handleError] and result in a 500 HTTP Response: - try { - const result = await processData(input); - return data(result); - } catch (error) { - // Throw 5xx errors - these trigger ErrorBoundary for the fetcher - throw data({ error: "" }, { status: 500 }); +```tsx +export function action() { + let db = await getDb(); + if (!db) { + // Fatal error - return a 500 response and trigger `handleError` + throw new Error("Could not connect to DB"); } + // ... } ``` -- **`return data()`**: For normal responses and expected errors (4xx). The data is available in `fetcher.data` and won't trigger an error boundary. -- **`throw data()`**: For fatal errors (5xx) that should trigger the error boundary. Use [`isRouteErrorResponse`](../api/utils/isRouteErrorResponse) in your [Error Boundary](./error-boundary) to handle these. - -### External-only access (REST API) - -If your resource route is only accessed externally (via `fetch`, `cURL`, direct browser navigation, etc.), treat it as a standard REST API endpoint. Always return `Response` objects with appropriate status codes: +If a resource route generates a `Response` (via `new Response()` or `data()`), it is considered a successful execution and will not trigger `handleError` because the API has successfully produced a Response for the HTTP request. This applies to thrown responses as well as returned responses with a 4xx/5xx status code. This behavior aligns with `fetch()` which does not return a rejected promise on 4xx/5xx Responses. -```tsx filename=api/users.ts -import type { Route } from "./+types/users"; - -export async function loader({ params }: Route.LoaderArgs) { - const user = await getUser(params.id); - - if (!user) { - // Return 404 for not found - return Response.json( - { error: "User not found" }, - { status: 404 }, - ); - } - - return Response.json(user); -} - -export async function action({ - request, -}: Route.ActionArgs) { - const formData = await request.formData(); - const email = formData.get("email"); - - if (!email || !isValidEmail(email)) { - // Return 400 for validation errors - return Response.json( - { error: "Invalid email" }, - { status: 400 }, - ); - } - - try { - const user = await createUser(email); - return Response.json(user, { status: 201 }); - } catch (error) { - // Unexpected errors will be caught and wrapped in a 500 response - // In production, error details are sanitized for security - throw error; - } +```tsx +export function action() { + // Non-fatal error - don't trigger `handleError`: + throw new Response( + { error: "Unauthorized" }, + { status: 401 }, + ); + + // These 3 are equivalent to the above + return new Response( + { error: "Unauthorized" }, + { status: 401 }, + ); + + throw data({ error: "Unauthorized" }, { status: 401 }); + + return data({ error: "Unauthorized" }, { status: 401 }); } ``` -When accessed externally, resource routes don't render error boundaries (there's no UI to render). Any thrown `Error` instances will be automatically wrapped in a 500 response and sanitized in production. +### Error Boundaries + +[Error Boundaries][error-boundary] are only applicable when a resource route is accessed from a UI, such as from a [`fetcher`][fetcher] call or a [``][form] submission. If you `throw` from your resource route in these cases, it will bubble to the nearest `ErrorBoundary` in the UI. -When a resource route using `data()` is accessed externally, React Router automatically converts the `data()` response to a `Response` object, allowing you to write flexible resource routes that work both ways. +[handleError]: ../api/framework-conventions/entry.server.tsx#handleerror +[data]: ../api/utils/data +[Response]: https://developer.mozilla.org/en-US/docs/Web/API/Response +[fetcher]: ../api/hooks/useFetcher +[form]: ../api/components/Form +[await]: ../api/components/Await +[error-boundary]: ../start/framework/route-module#errorboundary From b9e4d659d150cd51b124c7bae69c010a4b8ca62d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 10 Nov 2025 15:05:58 -0500 Subject: [PATCH 3/3] Remove section on raw json returns --- docs/how-to/resource-routes.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/how-to/resource-routes.md b/docs/how-to/resource-routes.md index 3dd060eaa8..dc682de1f8 100644 --- a/docs/how-to/resource-routes.md +++ b/docs/how-to/resource-routes.md @@ -76,8 +76,6 @@ Resource Routes are flexible when it comes to the return type - you can return [ - Keeps things consistent with the loaders/actions in your UI routes - Allows you to stream promises down to your UI through `data()`/[`Await`][await] -While we don't recommend it, you _can_ return plain JS objects from a resource route and they will be converted into an appropriate `Response` based on to the HTTP Request (a single-fetch response for `.data` requests or a JSON response for other external HTTP requests). We don't prefer this pattern because `new Response()`/`data()` calls keep the code explicit for API-only/non-UI routes. - ## Error Handling Throwing an `Error` from Resource route (or anything other than a `Response`/`data()`) will trigger [`handleError`][handleError] and result in a 500 HTTP Response: