Skip to content

Framework GuidesNext.js

Next.js

Full Next.js integration with apiRoute, createProxyQueryCaller, createCaller for RSC, and createAction for Server Actions.

7 min readboth modes

Courier has first-class Next.js support through the @noverax/courier-next package. It covers every Next.js pattern: API routes, client components with React Query, server components, and server actions.

WebSockets require a standalone server

Next.js App Router cannot host WebSocket upgrades in-process — neither RSC nor route handlers expose the underlying TCP socket. If your API uses WS endpoints, run @noverax/courier-server alongside Next.js and point proxyPath.ws at it (e.g. ws://localhost:3001 in dev, wss://realtime.example.com in prod). HTTP and SSE continue to work in-process via the App Router.

In one command

bash
npx @noverax/create-courier

In a Next.js app it adds what this page sets up by hand — the API route, the registration in instrumentation.ts and Studio's connection on both sides — with a typed example API, its mock, and a page at /courier. See Create Courier.

Studio integration & appOrigin

Wire your Next.js app into Studio with an attachRuntime() call in the register() of instrumentation.ts — the same function that registers your sources (Step 1). In development it loads courier.config.ts from disk, registers its sources, and opens the connection to Studio; in production the registerSources() call before it registers them. The appOrigin field for Playground proxy SSE/WS resolution lives in courier.config.ts.runtime.appOrigin (or set the COURIER_APP_URL env var to override per-environment).

instrumentation.ts
import { registerSources } from "@noverax/courier"

export async function register() {
  const { sources } = await import("./lib/sources")
  registerSources(sources)
  if (process.env.NEXT_RUNTIME !== "nodejs") return;
  if (process.env.NODE_ENV !== "development") return;
  const { attachRuntime } = await import("@noverax/courier-studio-core/runtime");
  await attachRuntime();
}

Alternatively, set the COURIER_APP_URL environment variable instead of pinning appOrigin in the config:

bash
COURIER_APP_URL=http://localhost:3000 next dev

No magic default

There is no localhost:3000 auto-fallback. If appOrigin is missing, clicking "Subscribe" on an SSE/WS endpoint in Studio Playground proxy mode throws ConfigurationError("APP_ORIGIN_NOT_CONFIGURED") with a pointer back to this section. The correct value depends on your runtime — custom port, Docker host, HTTPS in dev, reverse proxy — and a wrong guess would silently route requests to nowhere.

That connects the server. To see requests the browser makes, connect it too, from instrumentation-client.ts (Next.js 15.3 and later, under Turbopack or webpack):

instrumentation-client.ts
import { attachRuntime } from "@noverax/courier-studio-core/runtime";

if (process.env.NODE_ENV === "development") {
  attachRuntime("next-browser");
}

A browser bundle cannot read courier.config.ts: if Studio is not at its default address, add env: { COURIER_STUDIO_URL: "http://localhost:6290" } to next.config.ts. Under webpack, withCourierStudio() from @noverax/courier-studio/next connects the browser without the file, and keeps ws, which the server's Studio connection runs on, out of the server bundle; bundled, ws fails on every frame of 48 bytes or more and the server never reaches Studio. Under webpack without the wrapper, add serverExternalPackages: ["ws"] to next.config.ts.

Installation

bash
npm install @noverax/courier @noverax/courier-next @noverax/courier-react @tanstack/react-query

Define your API sources

Create a shared API definition used by all callers. This file can be imported on both server and client:

lib/sources.ts
import { courier, defineApi, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type User = { id: number; name: string; email: string }
type UserList = { data: User[]; total: number }

const users = defineGroup({
  prefix: "users",
  endpoints: {
    getAll: endpoint.get("").variables<{ page?: number; limit?: number }>().response<UserList>(),
    getById: endpoint.get(":id").variables<{ id: number }>().response<User>(),
    create: endpoint.post("").variables<{ name: string; email: string }>().response<User>(),
    update: endpoint.put(":id").variables<{ id: number; name: string; email: string }>().response<User>(),
    remove: endpoint.del(":id").variables<{ id: number }>().response<null>(),
  },
})

export const api = defineApi({
  baseURL: process.env.API_URL ?? "http://localhost:4000",
  groups: { users },
})

export const sources = { myApi: api }

Step 1: API Route

Create a Next.js App Router route handler that proxies browser requests to your backend, and register your sources at server start. Without Studio, this is the whole of instrumentation.ts; with it, use the register() under Studio integration, which does the same and attaches the runtime in development:

instrumentation.ts
import { registerSources } from "@noverax/courier"

export async function register() {
  const { sources } = await import("./lib/sources")
  registerSources(sources)
}
app/api/request/route.ts
import { apiRoute } from "@noverax/courier-next"

export const { GET, POST } = apiRoute()

Next.js runs instrumentation.ts once at server start, populating the core global registry that apiRoute() reads at request time. register() runs once in each runtime the app uses, and each runtime has its own registry: a route with export const runtime = "edge" reads the Edge runtime's, so the sources are registered in both. If your sources module imports Node-only code, register an Edge-safe one when process.env.NEXT_RUNTIME === "edge". sseRoute() streams in the Edge Runtime as well as in Node.js. The route handles all Courier proxy requests — parses the incoming POST body, resolves the correct endpoint, calls your backend, and returns the response.

Step 2: Client-side caller (React Query)

Create a query caller for use in client components:

lib/query.ts
import { createProxyQueryCaller } from "@noverax/courier-react"
import type { api } from "@/lib/sources"

export const q = createProxyQueryCaller<typeof api>("myApi")

Use it in client components:

app/users/page.tsx
"use client"

import { q } from "@/lib/query"

export default function UsersPage() {
  const { data, isLoading } = q.users.useQuery("getAll", {
    variables: { page: 1, limit: 10 },
  })

  const { mutate, isPending } = q.users.useMutation("create", {
    onSuccess: (user) => {
      console.log("Created:", user.name) // fully typed
    },
  })

  if (isLoading) return <p>Loading...</p>

  return (
    <div>
      <h1>Users ({data?.total})</h1>
      <ul>
        {data?.data.map((user) => (
          <li key={user.id}>{user.name} — {user.email}</li>
        ))}
      </ul>
      <button
        onClick={() => mutate({ name: "Jane", email: "jane@test.com" })}
        disabled={isPending}
      >
        Add User
      </button>
    </div>
  )
}

QueryClientProvider

Wrap your app with QueryClientProvider in your root layout. See the React Query setup page for details.

Step 3: Server Components (createServerCaller)

For React Server Components, use createServerCaller to call your backend directly without the proxy hop:

lib/server-caller.ts
import { createServerCaller } from "@noverax/courier-next"
import { api } from "@/lib/sources"

export const caller = createServerCaller(api)

It is createCaller that also finds the request Next.js is serving: an API that keeps a session gets the user's session from it, and a page whose calls carry one is rendered per request — see the user's session.

app/dashboard/page.tsx
import { caller } from "@/lib/server-caller"

export default async function DashboardPage() {
  const users = await caller.users.getAll({
    page: 1,
    limit: 5,
  })

  return (
    <div>
      <h1>Dashboard ({users.total} users)</h1>
      <ul>
        {users.data.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}

No proxy overhead

createServerCaller calls your backend directly from the server. There is no round-trip through the API route, making it ideal for SSR and RSC.

The user's session

Describe the session the API keeps where it is defined, and every server-side call made for a user carries that user's session, the cookies the API keeps it in and nothing else of your application's:

lib/sources.ts
import { courier, defineApi } from "@noverax/courier/define"

const { endpoint } = courier()

export const api = defineApi({
  name: "myApi",
  baseURL: process.env.API_URL!,
  session: { cookies: ["sessionid"], csrf: { cookie: "csrftoken", header: "x-csrftoken" } },
  endpoints: {
    me: endpoint.get("me").response<{ id: number; name: string }>(),
  },
})
WhereHow it finds the request
Pages, layouts, Route HandlerscreateServerCaller(api) registers nextRequestContext as its module loads
Server ActionscreateAction reads the action's own request; createServerCaller works too
Pages RouterwithRequestContext from @noverax/courier-next/pages wraps getServerSideProps and API routes
Client componentsA proxy caller: the proxy route reads the browser's request

The session cookies the API sets reach the browser from a Server Action and a Route Handler. Next.js lets no page set cookies while it renders, so a session the API renews during a render reaches that render's later calls only. With cacheComponents, put a component whose calls carry the session under <Suspense>, as you would one that reads cookies(), and keep such calls out of "use cache"; Next.js 14, 15 and 16 are covered alike. User Sessions covers the options and the writes another site sets off.

A page that writes while it renders

A write the API guards with a double-submit token asks for the token first when the user has none, and a page that renders cannot hand it to the browser: the next render asks again. A middleware hands it over with the page instead, where the request does not carry it, and gives it to the render too:

proxy.ts
import { csrfTokenMiddleware } from "@noverax/courier-next/middleware"
import { api } from "@/lib/sources"

export const proxy = csrfTokenMiddleware(api)
export const config = { matcher: ["/account/:path*"] }

Before Next.js 16 the file is middleware.ts and the export middleware. Import the API's definition alone there, not a module that makes a server caller. Match the pages that write while they render, or every page whose later writes should find the token waiting. A middleware of your own composes it with handCsrfTokens:

proxy.ts
import { handCsrfTokens } from "@noverax/courier-next/middleware"
import { type NextRequest, NextResponse } from "next/server"
import { api } from "@/lib/sources"

export async function proxy(request: NextRequest) {
  const tokens = await handCsrfTokens(request, api)
  const response = NextResponse.next({ request: { headers: tokens.headers } })
  response.headers.set("x-frame-options", "DENY")
  return tokens.handTo(response)
}

Next.js 14, 15 and 16 are checked with it: the token is asked once, the render's write repeats it, and the next visit asks nothing.

Step 4: Server Actions (createAction)

Use createAction for type-safe server actions that read the action's own request — its headers, and the session of an API that keeps one:

lib/actions.ts
import { createAction } from "@noverax/courier-next"
import { sources } from "@/lib/sources"

export const action = createAction(sources, "myApi")
app/actions/users.ts
"use server"

import { revalidatePath } from "next/cache"
import { action } from "@/lib/actions"

export async function createUser(data: { name: string; email: string }) {
  const user = await action.users.create(data)

  revalidatePath("/users")
  return user
}

export async function deleteUser(id: number) {
  await action.users.remove({ id })

  revalidatePath("/users")
}

Use server actions in client components:

app/users/create-form.tsx
"use client"

import { useActionState } from "react"
import { createUser } from "@/app/actions/users"

export function CreateUserForm() {
  const [state, formAction, isPending] = useActionState(
    async (_prev: unknown, formData: FormData) => {
      const result = await createUser({
        name: formData.get("name") as string,
        email: formData.get("email") as string,
      })
      return result
    },
    null
  )

  return (
    <form action={formAction}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create User"}
      </button>
    </form>
  )
}

Full project structure

Project structure
app/
  api/request/route.ts    # apiRoute()
  actions/users.ts        # server actions with createAction
  users/page.tsx          # client component with useQuery
  dashboard/page.tsx      # server component with createServerCaller
lib/
  sources.ts              # defineApi + defineGroup + endpoint
  endpoint.ts             # createProxyQueryCaller<typeof api>("myApi")
  server-caller.ts        # createServerCaller(api)
  actions.ts              # createAction(sources, "myApi")

Use both modes together

Most Next.js apps use all three: createProxyQueryCaller for client components, createServerCaller for server components, and createAction for server actions. They all share the same source definitions, and the same session.