Executing api requests in React Router

There's not a lot of opinions on how to structure code that interacts with apis in React Router's actions and loaders. This is what I've come up with and the post I wish existed before I started using React Router's actions/loaders with my existing api(and if middleware existed back then). You need some understanding of how context works which you can learn about in the middleware docs.

getLoadContext

This is where you can define context values before any of your actions and loaders get hit.

// server.ts
import { createRequestHandler } from "@react-router/express";
import express from "express";
import { unstable_RouterContextProvider } from "react-router";
import { getSession } from "~/services/session.server";
import { appContext, makeFetchTyped } from "~/contexts.server";

export const app = express();

app.use(
  createRequestHandler({
    build: () => import("virtual:react-router/server-build"),
    async getLoadContext(req) {
      const session = await getSession(req.headers.cookie);
      const user = session.get("user");
      const fetchTyped = makeFetchTyped(user);
      const context = new unstable_RouterContextProvider();
      context.set(appContext, {
        user,
        fetchTyped,
      });

      return context;
    },
  }),
);

Now you have access to the the appContext values in your actions and loaders.

import { appContext } from '~/contexts.server'

export async function loader({ context }: Route.LoaderArgs) {
  const ctx = context.get(appContext)
  // ctx.user
  // ctx.fetchTyped
}

See this middleware project example for all the pieces to make this work: vite.config.ts, react-router.config.ts, etc.

Middleware

You can also skip getLoadContext and do this directly in middleware.

// app/root.tsx
const rootMiddleware: Route.unstable_MiddlewareFunction = async ({
  request,
  context,
}) => {
  const session = await getSession(request.headers.get("Cookie"));
  const user = session.get("user");
  const fetchTyped = makeFetchTyped(user);
  context.set(appContext, {
    user,
    fetchTyped,
  });
};

export const unstable_middleware = [rootMiddleware];

The middleware version is the way of the future. I only mention the getLoadContext because you might come across it being mentioned and be confused since it shares a lot of similarity with middleware.

fetchTyped

What is fetchTyped? Another concept I picked up from Bryan Ross(@rossipedia) in React Router's discord. This is a function I've defined in my codebase. When dealing with an api, you're usually making authenticated requests to a particular user via auth token that's stored in the session. fetchTyped is a thin wrapper around fetch that binds the current user to a fetch wrapper. It accepts a zod schema to validate the response. Here's the implementation.

import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { env } from '~/env.server'

export function makeFetchTyped(user: User) {
    const defaultHeaders: HeadersInit = {
        Accept: 'application/json',
        'Content-Type': 'application/json',
        Authorization: user.apiToken,
    }

    return async function fetchTyped<T extends z.ZodType>(
        url: string,
        schema: T,
        options?: RequestInit,
    ): Promise<z.infer<T>> {
          // Merge the default headers with the provided options
          const mergedOptions: RequestInit = {
            ...options,
            headers: {
              ...defaultHeaders,
              ...(options.headers || {}),
            },
          }
        const res = await fetch(env.API_HOST + url, mergedOptions)
        const json = await res.json()
        try {
          return schema.parse(json)
        } catch (reason) {
          throw reason instanceof z.ZodError ? fromZodError(reason) : reason
        }
    }
}

export type FetchTyped = ReturnType<typeof makeFetchTyped>

And here's how it can be used.

import { redirect, href, data } from "react-router";
import { appContext } from "~/contexts.server";
import { z } from "zod";
import type { Route } from "./+types/route";

export async function loader({ context }: Route.LoaderArgs) {
  const ctx = context.get(appContext);
  const schema = z.object({
    data: z.array(
      z.object({
        id: z.number(),
        firstName: z.string(),
        lastName: z.string(),
      }),
    ),
  });
  const res = await ctx.fetchTyped("/contacts", schema);
  return {
    contacts: res.data,
  };
}

export async function action({ request, context }: Route.LoaderArgs) {
  const formData = await request.formData();
  const ctx = context.get(appContext);
  const schema = z
    .object({
      id: z.string(),
      message: z.string(),
    })
    .or(
      z.object({
        error: z.string(),
      }),
    );
  const contactId = String(formData.get("contactId"));
  const res = await ctx.fetchTyped(`/contacts/${contactId}`, schema, {
    method: "PUT",
    body: JSON.stringify({
      data: {
        firstName: String(formData.get("firstName") ?? ""),
        lastName: String(formData.get("lastName") ?? ""),
      },
    }),
  });

  if ("error" in res) {
    return data({ error: res.error }, { status: 400 });
  }

  return redirect(href('/app/contacts/:id', { id: res.id }));
}

Organization

Defining your api requests directly in your actions and loaders is totally valid but it leaves a lot to be desired in terms of code organization. If we extract these interactions into a class, we can organize and reuse these methods across the application if needed.

// app/service/contact-client.server.ts
import type { FetchTyped } from "~/lib/fetch-typed.server";
import { DomainError } from "~/lib/validation.server";

export class ContactClient {
    #fetchTyped: FetchTyped;

    constructor(fetchTyped: FetchTyped) {
        this.#fetchTyped = fetchTyped;
    }

    async getContacts() {
        const schema = z.object({
            data: z.array(
                z.object({
                    id: z.number(),
                    firstName: z.string(),
                    lastName: z.string(),
                }),
            ),
        });
        const { data } = await this.#fetchTyped("/contacts", schema);
        return data;
    }

    async updateContact({
        contactId,
        values,
    }: {
        contactId: string;
        values: CreateContactValues;
    }) {
        const schema = z
            .object({
                id: z.string(),
                firstName: z.string(),
                lastName: z.string(),
            })
            .or(
                z.object({
                    error: z.string(),
                }),
            );
        const res = await this.#fetchTyped(`/contacts/${contactId}`, schema, {
            method: "PUT",
            body: JSON.stringify({
                data: values,
            }),
        });
        if ("error" in res) {
            throw new DomainError(res.error);
        }
        return res;
    }
}

type CreateContactValues = {
    firstName: string;
    lastName: string;
};

Next, well add this to our context.

const rootMiddleware: Route.unstable_MiddlewareFunction = async ({
  request,
  context,
}) => {
  const session = await getSession(request.headers.get("Cookie"));
  const user = session.get("user");
  const fetchTyped = makeFetchTyped(user);
  context.set(appContext, {
    user,
    fetchTyped, 
    // you'll need to add contactClient to the AppContext type too
    contactClient: new ContactClient(fetchTyped)
  });
};

And update our route to use it.

import { redirect, href, data } from "react-router";
import { appContext } from "~/contexts.server";
import { z } from "zod";
import type { Route } from "./+types/route";
import { DomainError } from "~/validation.server";

export async function loader({ context }: Route.LoaderArgs) {
  const ctx = context.get(appContext);
  const contacts = await ctx.contactClient.getContacts();
  return {
    contacts,
  };
}

export async function action({ request, context }: Route.LoaderArgs) {
  const ctx = context.get(appContext);
  const formData = await request.formData();
  try {
    const contact = await ctx.contactClient.updateContact({
      contactId: String(formData.get("contactId")),
      values: {
        firstName: String(formData.get("firstName") ?? ""),
        lastName: String(formData.get("lastName") ?? ""),
      },
    });
    redirect(href('/app/contacts/:id', { id: contact.id }));
  } catch (e) {
    if (e instanceof DomainError) {
      return data({ error: e.message }, { status: 400 });
    }
    throw e;
  }
}

I like this for several reasons.

  • We have a central location to define api requests related to this resource.
  • We're hiding the implementation of how the data is fetched and persisted. If we decide we want to hit a database directly, instead of going through an api, this code doesn't need to change. We could set up a database connection and feed that to the class constructor instead of fetchTyped and the route code would be none the wiser.
  • We can come up with style "rules" to make less thinking involved when developing new code.

Rules

Here are a few rules i've documented in my project repo to share with my team.


Organization

All api interactions should be located in app/module/<module-name>/<resouce-name>-client.server.tsx. The resource name should be singular. Ex. note-client.server.tsx, not notes-client.server.tsx.

Class methods

The method names should not allude to an api or database being used.

Good:

  • getNote
  • getNotes
  • createNote
  • updateNote
  • deleteNote

Bad:

  • fetchNote
  • queryNote

These methods should only contain one configuration object. Descriptive id field names should be used to decrease ambiguity inside the configuration object. Ex. contactId, not id.

Errors should be thrown inside of a DomainError, not returned as data.

The functions should be ui agnostic. For example, they should not return toast messages. This should be done in the route files. Example:

// app/routes/app.contacts.$id/route.tsx

export async function action({ request, params, context }: Route.ActionArgs) {
  const formData = await request.formData();
  const ctx = getAppContext(context);
  const result = await updateContact(ctx.contactClient, { // see updateContact below
    contactId: params.id,
    values: {
      firstname: String(formData.get("firstname") ?? ""),
      // ...
    },
  });

  // use react router's data() utility to send proper status code
  return data(result, { status: result.success ? 200 : 400 });
}

// clientAction should be origin of toasts, not useEffects
export async function clientAction({ serverAction }: Route.ClientActionArgs) {
  const result = await serverAction();
  toast({
    title: result.toast.title,
    variant: result.toast.variant,
  });
  return result;
}

// Wrapper function to put together toast messages based on the response.
// This code is specific to this use case of the api function.
async function updateContact(
  contactClient: ContactClient,
  ...args: Parameters<ContactClient['updateContact']>
) {
  try {
    const contact = await contactClient.updateContact(...args);
    return {
      success: true,
      contact,
      toast: {
        title: "Contact updated!",
        variant: "default",
      },
    };
  } catch (e) {
    if (e instanceof DomainError) {
      return {
        success: false,
        contact: null,
        toast: {
          title: e.message,
          variant: "destructive",
        },
      };
    }

    throw e;
  }
}

You may absolutely hate these rules but that's not really the point. The point is that your project will benefit if you spend some time figuring out how to make your app consistently built. Having a "standard" way to do things will make it so much easier to develop new features. There are less decisions to make in the moment. They've already been made and documented. Not only will this help your human coworkers, but also Copilot, Cursor, Claude Code, OpenCode, Junie, etc.

Do you have some opinions on how to structure your react router applications? I'd love to hear what those are. Reach out to me on twitter.

Happy building ✌️

Categories: React, Javascript, Node, Typescript