Resource Routes
On this page

Resource Routes



When server rendering, routes can serve "resources" instead of rendering components, like images, PDFs, JSON payloads, webhooks, etc.

Defining a Resource Route

A route becomes a resource route by convention when its module exports a loader or action but does not export a default component.

Consider a route that serves a PDF instead of UI:

route("/reports/pdf/:id", "pdf-report.ts");
import type { Route } from "./+types/pdf-report";

export async function loader({ params }: Route.LoaderArgs) {
  const report = await getReport(params.id);
  const pdf = await generateReportPDF(report);
  return new Response(pdf, {
    status: 200,
    headers: {
      "Content-Type": "application/pdf",
    },
  });
}

Note there is no default export. That makes this route a resource route.

Linking to Resource Routes

When linking to resource routes, use <a> or <Link reloadDocument>, otherwise React Router will attempt to use client side routing and fetching the payload (you'll get a helpful error message if you make this mistake).

<Link reloadDocument to="/reports/pdf/123">
  View as PDF
</Link>

Handling different request methods

GET requests are handled by the loader, while POST, PUT, PATCH, and DELETE are handled by the action:

import type { Route } from "./+types/resource";

export function loader(_: Route.LoaderArgs) {
  return Response.json({ message: "I handle GET" });
}

export function action(_: Route.ActionArgs) {
  return Response.json({
    message: "I handle everything else",
  });
}

Return Types

Resource Routes are flexible when it comes to the return type - you can return Response instances or data() objects. A good general rule of thumb when deciding which type to use is:

  • 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 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

Error Handling

Throwing an Error from Resource route (or anything other than a Response/data()) will trigger handleError and result in a 500 HTTP Response:

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");
  }
  // ...
}

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.

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 });
}

Error Boundaries

Error Boundaries are only applicable when a resource route is accessed from a UI, such as from a 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.

Docs and examples CC 4.0
Edit