Docs Navigation

Resource Routes

Resource Routes are not part of your application UI, but are still part of your application. They can send any kind of Response.

Most routes in Remix are UI Routes, or routes that actually render a component. But routes don't always have to render components. There are a handful of cases where you want to use route as a general purpose endpoint to your website. Here are a few examples:

  • JSON API for a mobile app that reuses server-side code with the Remix UI
  • Dynamically generating PDFs
  • Dynamically generating social images for blog posts or other pages
  • Webhooks for other services like Stripe or GitHub
  • a CSS file that dynamically renders custom properties for a user's preferred theme

Creating Resource Routes

If a route doesn't export a default component, it can be used as a Resource Route. If called with GET, the loader's response is returned and none of the parent route loaders are called either (because those are needed for the UI, but this is not the UI). If called with POST, the action's response is called.

For example, consider a UI Route that renders a report, note the link:

export async function loader({ params }) {
  return json(await getReport(;

export default function Report() {
  const report = useLoaderData();
  return (
      <Link to="pdf" reloadDocument>
        View as PDF
      {/* ... */}

It's linking to a PDF version of the page. To make this work we can create a Resource Route below it. Notice that it has no component: that makes it a Resource Route.

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

When the user clicks the link from the UI route, they will navigate to the PDF.

Linking to Resource Routes

It’s imperative that you use reloadDocument on any Links to Resource Routes

There's a subtle detail to be aware of when linking to resource routes. You need to link to it with <Link reloadDocument> or a plain <a href>. If you link to it with a normal <Link to="pdf"> without reloadDocument, then the resource route will be treated as a UI route. Remix will try to get the data with fetch and render the component. Don't sweat it too much, you'll get a helpful error message if you make this mistake.

URL Escaping

You'll probably want to add a file extension to your resource routes. This is tricky because one of Remix's route file naming conventions is that . becomes / so you can nest the URL without nesting the UI.

To add a . to a route's path, use the [] escape characters. Our PDF route file name would change like so:

# original
# /reports/123/pdf

# with a file extension
# /reports/123.pdf

# or like this, the resulting URL is the same

Handling different request methods

To handle GET requests export a loader function:

import { json } from "@remix-run/node"; // or cloudflare/deno
import type { LoaderFunction } from "@remix-run/node"; // or cloudflare/deno

export const loader: LoaderFunction = async ({
}) => {
  // handle "GET" request

  return json({ success: true }, 200);

To handle POST, PUT, PATCH or DELETE requests export an action function:

import type { ActionFunction } from "@remix-run/node"; // or cloudflare/deno

export const action: ActionFunction = async ({
}) => {
  switch (request.method) {
    case "POST": {
      /* handle "POST" */
    case "PUT": {
      /* handle "PUT" */
    case "PATCH": {
      /* handle "PATCH" */
    case "DELETE": {
      /* handle "DELETE" */


Resource routes can be used to handle webhooks. For example, you can create a webhook that receives notifications from GitHub when a new commit is pushed to a repository:

import type { ActionFunction } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import crypto from "crypto";

export const action: ActionFunction = async ({
}) => {
  if (request.method !== "POST") {
    return json({ message: "Method not allowed" }, 405);
  const payload = await request.json();

  /* Validate the webhook */
  const signature = request.headers.get(
  const generatedSignature = `sha256=${crypto
    .createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET)
  if (signature !== generatedSignature) {
    return json({ message: "Signature mismatch" }, 401);

  /* process the webhook (e.g. enqueue a background job) */

  return json({ success: true }, 200);