Skip to content

Framework GuidesNuxt

Nuxt

Nuxt integration with courierHandler for server routes, createProxyCaller for client-side, and createCaller for server-side data fetching.

5 min readUpdated both modes

Courier works with Nuxt through the @noverax/courier-nuxt adapter. Use courierHandler for the server proxy route, createProxyCaller for client-side requests, and createCaller for server-side data fetching.

In one command

bash
npx @noverax/create-courier

In a Nuxt app it adds what this page sets up by hand — the server route, the Nitro plugin that registers your APIs, and Studio's module — with a typed example API, its mock, and a page at /courier. See Create Courier.

Installation

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

Define your API sources

Create the API definition the server route and Studio share. A page never imports it: it takes the routes Courier writes from it, so the definition stays out of the browser bundle.

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

const { endpoint } = courier()

type Post = { id: number; title: string; body: string }
type PostList = { data: Post[]; total: number }

const posts = defineGroup({
  prefix: "posts",
  // A page may call these through the proxy; an endpoint no mark opens stays the server's
  expose: true,
  endpoints: {
    getAll: endpoint.get("").variables<{ page?: number }>().response<PostList>(),
    getById: endpoint.get(":id").variables<{ id: number }>().response<Post>(),
    create: endpoint.post("").variables<{ title: string; body: string }>().response<Post>(),
    remove: endpoint.del(":id").variables<{ id: number }>().response<null>(),
  },
})

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

export const sources = { myApi: api }

Step 1: Server route

Create a Nuxt server route that proxies client requests to your backend:

server/plugins/courier.ts
import { registerSources } from "@noverax/courier"
import { sources } from "~/lib/sources"

export default defineNitroPlugin(() => {
  registerSources(sources)
})
server/api/request.ts
import { courierHandler } from "@noverax/courier-nuxt"

export default courierHandler()

The server/plugins/courier.ts Nitro plugin runs once at server start, populating the in-process registry that courierHandler() reads at request time. The route answers every method at /api/request, the proxy target Courier clients use: a read that may be cached arrives as GET, any other call as POST, and an SSE stream as GET.

Step 2: Client-side composable

List your sources in courier.config.ts. From the endpoints they mark .expose(), Courier writes the routes a page's caller takes into courier.routes.ts beside it — Studio whenever it loads the config, or npx @noverax/courier-cli routes now; see Exposing Endpoints:

courier.config.ts
import { defineConfig } from "@noverax/courier/config"
import { sources } from "./lib/sources"

export default defineConfig({ sources })

Create a composable using createProxyCaller for client-side requests, from the routes of myApi:

composables/useCaller.ts
import { createProxyCaller } from "@noverax/courier"
import { myApiRoutes } from "~/courier.routes"

export function useCaller() {
  return createProxyCaller(myApiRoutes, {
    proxyPath: { http: "/api/request" },
  })
}

useAsyncData runs on the server first. There the call does not leave the server: the proxy caller reaches the proxy in the same process, where the source is registered, so the path needs no address to resolve against, and the call carries the session of the user the page is rendered for once each request is served with its session at hand — see the user's session. In the browser the same path goes to the route over HTTP.

Use it in your pages and components:

pages/posts.vue
<script setup lang="ts">
const caller = useCaller()

const { data: posts, status } = await useAsyncData("posts", () =>
  caller.posts.getAll({ page: 1 })
)
</script>

<template>
  <div>
    <p v-if="status === 'pending'">Loading...</p>
    <template v-else-if="posts">
      <h1>Posts ({{ posts.total }})</h1>
      <ul>
        <li v-for="post in posts.data" :key="post.id">
          {{ post.title }}
        </li>
      </ul>
    </template>
  </div>
</template>

Step 3: Server-side data fetching

For server-side data fetching in event handlers, use createCaller directly:

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

export const caller = createCaller(api)

Use it in Nuxt server routes or event handlers:

server/api/dashboard.get.ts
import { caller } from "~/lib/server-caller"

export default defineEventHandler(async () => {
  const posts = await caller.posts.getAll({ page: 1 })

  return {
    totalPosts: posts.total,
    latestPosts: posts.data.slice(0, 5),
  }
})

Using with useFetch

You can also combine the server caller with useFetch for SSR data that hydrates on the client:

pages/posts/[id].vue
<script setup lang="ts">
const route = useRoute()

const { data: post } = useFetch(`/api/posts/${route.params.id}`)
</script>

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
    <p>{{ post.body }}</p>
  </article>
</template>
server/api/posts/[id].get.ts
import { caller } from "~/lib/server-caller"

export default defineEventHandler(async (event) => {
  const id = Number(getRouterParam(event, "id"))

  return await caller.posts.getById({ id })
})

Mutations

Handle mutations by calling the proxy caller from client-side event handlers:

pages/posts/create.vue
<script setup lang="ts">
const caller = useCaller()

const title = ref("")
const body = ref("")
const isSubmitting = ref(false)

async function handleSubmit() {
  isSubmitting.value = true
  try {
    const post = await caller.posts.create({
      title: title.value,
      body: body.value,
    })
    navigateTo(`/posts/${post.id}`)
  } finally {
    isSubmitting.value = false
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="title" placeholder="Title" required />
    <textarea v-model="body" placeholder="Body" required />
    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? "Creating..." : "Create Post" }}
    </button>
  </form>
</template>

@tanstack/vue-query

For advanced caching and mutation management, consider using @tanstack/vue-query alongside createProxyCaller. Courier proxy callers return standard promises, so they work seamlessly with any data-fetching library.

The user's session

An API that keeps a session gets the user's session with every call a page's setup, a useAsyncData, a plugin or a server route makes, through every await, once Nitro serves each request with it at hand:

server/plugins/courier-request.ts
import { requestContextPlugin } from "@noverax/courier-nuxt"

export default defineNitroPlugin(requestContextPlugin)

The plugin wraps the handler Nitro serves every request with, as Nitro does for its own asyncContext. The session cookies the API sets reach the browser on the response, during server rendering as from a server route. See User Sessions.

Realtime (SSE & WebSocket)

courierHandler() serves SSE streams as well as HTTP calls: a request that asks for text/event-stream gets a stream, and the stream ends when its client leaves. Point proxyPath.sse at the same route.

WebSocket channels are not served by the Nuxt adapter: Nitro's WebSocket layer (crossws) is still flagged experimental, and its behavior differs across deployment targets (node-server, bun, cloudflare, vercel-edge). Run a standalone Courier server alongside Nuxt for WebSocket endpoints, and point proxyPath.ws at it:

composables/useCaller.ts
import { createProxyCaller } from "@noverax/courier"
import { myApiRoutes } from "~/courier.routes"

export function useCaller() {
  const { origin } = useRequestURL()
  return createProxyCaller(myApiRoutes, {
    proxyPath: {
      http: "/api/request",                       // handled by Nuxt — in process while rendering
      sse: `${origin}/api/request`,               // handled by Nuxt — a stream always opens over HTTP
      ws: "ws://localhost:4400/api/ws",           // handled by standalone server
    },
  })
}

Studio integration & appOrigin

Wire your Nuxt server into Studio with a single attachRuntime() call from a Nitro plugin. In development it loads courier.config.ts from disk, registers its sources, and opens the connection to Studio; in production the app's own registerSources() call registers them. Studio's Playground sends its proxy calls to the app's origin, which in development Studio's Nuxt module hands Nitro as nuxt dev starts listening. Where Studio cannot see the server, set courier.config.ts.runtime.appOrigin or the COURIER_APP_URL env var — see How the app learns its origin.

server/plugins/courier-studio.ts
export default defineNitroPlugin(async () => {
  if (import.meta.dev) {
    const { attachRuntime } = await import("@noverax/courier-studio-core/runtime")
    await attachRuntime()
  }
})

The runtime is imported inside the import.meta.dev check: a production build drops the whole branch, so .output carries no Studio code. Imported at the top of the file instead, the runtime would be copied into .output, with its Socket.IO client and its TypeScript loader, for a plugin that never runs there.

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

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

No magic default

There is no localhost:3000 auto-fallback. Until the config, the environment or the server itself names the origin, a Playground call in proxy mode throws ConfigurationError("APP_ORIGIN_NOT_CONFIGURED") with a pointer to how the app learns its origin. The correct value depends on your runtime — custom port, Docker host, HTTPS, reverse proxy — and a wrong guess would silently route requests to nowhere.

Project structure

courier.config.ts           # defineConfig({ sources })
courier.routes.ts           # written by Studio from the .expose() marks
composables/
  useCaller.ts              # useCaller() → createProxyCaller(myApiRoutes)
lib/
  sources.ts                # defineApi + defineGroup + endpoint
  server-caller.ts          # createCaller(api)
server/
  api/
    request.ts              # courierHandler()
    dashboard.get.ts        # server-side with createCaller
    posts/[id].get.ts       # dynamic server route
pages/
  posts.vue                 # client-side with useAsyncData
  posts/create.vue          # mutations
  posts/[id].vue            # dynamic page with useFetch