Skip to content

Framework GuidesNuxt

Nuxt

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

4 min readboth 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 a shared API definition:

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",
  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 arrives as GET, a write as POST, and an SSE stream as GET.

Step 2: Client-side composable

Create a composable using createProxyCaller for client-side requests:

composables/useCaller.ts
import { createProxyCaller } from "@noverax/courier"
import type { api } from "~/lib/sources"

export function useCaller() {
  return createProxyCaller<typeof api>("api", {
    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 type { api } from "~/lib/sources"

export function useCaller() {
  const { origin } = useRequestURL()
  return createProxyCaller<typeof api>("api", {
    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. 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).

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. 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, reverse proxy — and a wrong guess would silently route requests to nowhere.

Project structure

composables/
  useCaller.ts              # useCaller() → createProxyCaller<typeof api>("api")
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