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
npx @noverax/create-courierIn 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
npm install @noverax/courier @noverax/courier-nuxtDefine 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.
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:
import { registerSources } from "@noverax/courier"
import { sources } from "~/lib/sources"
export default defineNitroPlugin(() => {
registerSources(sources)
})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:
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:
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:
<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:
import { createCaller } from "@noverax/courier"
import { api } from "~/lib/sources"
export const caller = createCaller(api)Use it in Nuxt server routes or event handlers:
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:
<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>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:
<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:
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:
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.
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:
COURIER_APP_URL=http://localhost:3000 nuxt devNo 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