Skip to content

Framework GuidesRemix & React Router

Remix & React Router

Serve Courier's proxy from a React Router 7 or Remix v2 resource route, with @noverax/courier-remix.

3 min readUpdated proxy mode

@noverax/courier-remix serves the proxy that createProxyCaller and createProxyQueryCaller call: one route answers every endpoint, reads over GET and writes over POST, and streams server-sent events.

In one command

bash
npx @noverax/create-courier

In a React Router or Remix app it adds what this page sets up by hand — the resource route, its entry in app/routes.ts, and Studio's plugin — with a typed example API, its mock, and a page at /courier. See Create Courier.

Install

bash
npm install @noverax/courier-remix @noverax/courier

Remix continues as React Router 7's framework mode, which is what npx create-react-router@latest sets up, and the handler works with both.

Usage

ts
// app/routes/api.request.ts
import { registerSources } from "@noverax/courier"
import { courierHandler } from "@noverax/courier-remix"
import { sources } from "~/api"

registerSources(sources)

const handler = courierHandler()
export const loader = handler
export const action = handler

React Router 7 serves only the routes app/routes.ts lists, so add the file there:

ts
// app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes"

export default [
  index("routes/home.tsx"),
  route("api/request", "routes/api.request.ts"),
] satisfies RouteConfig

On Remix v2, whose file-system routes serve app/routes/api.request.ts at /api/request without a config, the route file alone is enough.

courierHandler() is zero-arg — sources are read at request time from the core global registry that registerSources() fills when the route module loads. The handler uses Web Standard Request/Response natively — no conversion needed. loader answers GET, which carries a read that may be cached, and action answers POST, which carries every other call.

The proxy reads a JSON call up to its maxBodyBytes, 1 MB, and an upload (multipart/form-data) up to its maxUploadBytes, 10 MB; pass others to raise them: courierHandler({ maxUploadBytes: 50 * 1024 * 1024 }). In development, a client that declares an oversized upload can see the connection close before it has sent it all, rather than read the 413.

The client

Pair with createProxyCaller on the client, given the routes Courier writes into courier.routes.ts from the endpoints you mark .expose() — see Exposing Endpoints:

ts
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "./courier.routes"

// The routes name the key this source has in your `sources` map —
// it is what the proxy addresses, and it travels as `?_s=<name>`.
const caller = createProxyCaller(apiRoutes, {
  proxyPath: { http: "/api/request" },
})
const users = await caller.users.getAll()

The user's session

An API that keeps a session gets the user's session with every call a loader, an action or a step it awaits makes, once the request is served with it at hand. List the middleware in the root route:

app/root.tsx
import { requestContext } from "@noverax/courier-remix/request-context"

export const middleware = [requestContext()]

React Router 8 runs route middleware as it is; React Router 7 runs it with future: { v8_middleware: true } in react-router.config.ts. The session cookies the API sets reach the browser on the response, a document's or a data request's. The request is kept in AsyncLocalStorage, which Node provides, and Cloudflare with the nodejs_compat flag; the main entry imports none of it.

react-router-serve hands React Router a Request alone, so forward's client address is not sent. An Express server of your own tells it: serve the requests with requestContext from @noverax/courier-express instead. A server of your own that calls the handler createRequestHandler makes — Bun's, Deno's, a Worker's — wraps it:

server.ts
import { createRequestHandler } from "react-router"
import { withRequestContext } from "@noverax/courier-remix/request-context"

const handle = withRequestContext(createRequestHandler(build, "production"))

Remix v2 has no middleware. Serve its handler from an Express server of your own — the one its Express template starts with — and mount requestContext from @noverax/courier-express before it:

server.js
import { requestContext } from "@noverax/courier-express"
import { createRequestHandler } from "@remix-run/express"

app.use(requestContext())
app.all("*", createRequestHandler({ build }))

React Router 7 and 8 are checked with react-router-serve, and Remix v2 with its Express template's server, compression included. See User Sessions.

Cancelled calls

When the browser cancels a call — an aborted upload, a query it no longer needs — the proxy stops its own upstream request, following the request's signal. React Router 7 hands a resource route a rebuilt copy of the request, and the copy's signal does not reliably follow the original: a cancelled upload to a slow upstream was seen to run to completion on both react-router dev and react-router-serve. The future flag that passes the request through unchanged keeps the cancellation:

ts
// react-router.config.ts
export default {
  future: { v8_passThroughRequests: true },
}