SSE in Nuxt 3 Permalink to this section
Part of Vue EventSource Composables, under Frontend Consumption & Client Patterns.
Nuxt 3 is a full-stack framework: its Nitro server can produce a Server-Sent Events stream, and its Vue front end can consume one. Both sides have Nuxt-specific details. On the server, Nitro’s event handlers and h3 utilities have their own way to stream. On the client, pages render on the server first, so composables must avoid creating connections during SSR, and the initial data should come from useFetch so the first paint is real. This guide covers the full loop.
Symptom & Developer Intent Permalink to this section
- A Nitro route streams correctly in development but buffers behind the production host.
EventSource is not definedbreaks server rendering of pages that use a stream composable.- Pages hydrate with empty state, then fill in, and hydration warnings appear.
- Every component using the composable opens its own connection.
- Streams stay open after leaving the page.
The intent is a Nitro stream route, a client-only composable sharing one connection, and pages that render real data on the server.
Root Cause Analysis Permalink to this section
On the server side, a Nitro handler that returns a value ends the response; streaming requires returning a stream or using h3’s event-stream helper, and the host’s proxy must not buffer. On the client side, setup() code runs during SSR too, so a composable that creates an EventSource at setup time runs on the server. Lifecycle hooks like onMounted run only in the browser, which is where the connection belongs.
Step-by-Step Resolution Permalink to this section
Step 1 — Stream from a Nitro server route Permalink to this section
// server/api/orders/stream.get.ts
export default defineEventHandler(async (event) => {
const eventStream = createEventStream(event); // h3 helper: headers + framing
const after = getHeader(event, 'last-event-id') ?? getQuery(event).after ?? '';
for (const e of await replayAfter(String(after))) {
await eventStream.push({ id: e.id, event: e.type, data: JSON.stringify(e.data) });
}
const off = bus.subscribe((e) => eventStream.push({ id: e.id, event: e.type, data: JSON.stringify(e.data) }));
const hb = setInterval(() => eventStream.push({ comment: 'hb' }), 15_000); // comment support varies by h3 version
eventStream.onClosed(async () => { off(); clearInterval(hb); await eventStream.close(); });
return eventStream.send();
});
createEventStream sets the headers and formats frames. If your h3 version lacks it, return a ReadableStream with the headers set by hand — the same approach as in serving SSE with Bun and Deno. Deployed behind nginx or a platform proxy, add X-Accel-Buffering: no and check the host’s streaming support and duration limits.
Step 2 — Load a snapshot with useFetch Permalink to this section
<!-- pages/orders.vue -->
<script setup lang="ts">
const { data } = await useFetch('/api/orders/snapshot'); // runs on the server, serialised into the payload
const orders = useLiveOrders(data.value!.orders, data.value!.cursor);
</script>
<template>
<OrdersTable :orders="orders" />
</template>
Step 3 — A client-only composable that shares one connection Permalink to this section
// composables/useLiveOrders.ts
let es: EventSource | null = null;
let users = 0;
const listeners = new Set<(e: MessageEvent) => void>();
export function useLiveOrders(initial: Order[], cursor: string) {
const orders = ref(initial); // same value on server and client
const onEvent = (e: MessageEvent) => {
const d = JSON.parse(e.data);
if (e.type === 'order.updated') {
const i = orders.value.findIndex((o) => o.id === d.id);
i >= 0 ? orders.value.splice(i, 1, d) : orders.value.unshift(d);
}
if (e.type === 'order.deleted') orders.value = orders.value.filter((o) => o.id !== d.id);
};
onMounted(() => { // browser only
listeners.add(onEvent);
if (users++ === 0) {
es = new EventSource(`/api/orders/stream?after=${encodeURIComponent(cursor)}`);
for (const t of ['order.updated', 'order.deleted']) {
es.addEventListener(t, (e) => listeners.forEach((fn) => fn(e as MessageEvent)));
}
}
});
onBeforeUnmount(() => {
listeners.delete(onEvent);
if (--users === 0) { es?.close(); es = null; } // last user gone
});
return orders;
}
The module-level connection is shared by every component using the composable, reference-counted by mounts. Module state in a composable is per browser tab on the client — but it is shared across requests on the server, which is why nothing connection-related runs outside onMounted.
Step 4 — Or provide the connection from a client plugin Permalink to this section
For app-wide streams (notifications, presence), a client-only plugin is simpler:
// plugins/stream.client.ts — the .client suffix means it never runs on the server
export default defineNuxtPlugin(() => {
const bus = useEventBus();
const es = new EventSource('/api/stream', { withCredentials: true });
for (const t of EVENT_TYPES) es.addEventListener(t, (e) => bus.emit(t, JSON.parse((e as MessageEvent).data)));
return { provide: { stream: bus } };
});
Components use useNuxtApp().$stream to listen. The connection lasts for the life of the tab, which suits app-wide feeds. The general composable pattern is in creating a Vue composable for Server-Sent Events.
Step 5 — Feed Nitro from a shared bus and authenticate Permalink to this section
The bus imported by the Nitro route must be shared across every Nitro instance. In a single-process deployment an in-memory emitter works; with several instances — or on serverless presets where each request may run in a separate instance — subscribe to Redis or another broker inside the route and unsubscribe in onClosed. Nitro’s storage layer and plugins are convenient places to hold one broker connection per instance rather than one per stream.
Authentication follows the usual rules for EventSource: cookies work unchanged, since the request is same-origin and the session cookie is sent automatically; bearer tokens require a fetch-based client in the composable instead. Read the session inside the handler with the same utilities the rest of the Nitro API uses, and reject with createError({ statusCode: 401 }) before creating the event stream, so the browser’s EventSource stops rather than retrying against a request that will never succeed. For per-user streams, derive the subscription from the session, never from query parameters, as discussed in isolating tenants on shared SSE endpoints.
Pinia works well as the destination for streamed data in larger Nuxt apps: the composable or plugin dispatches events into store actions, and components read store state. The shape is covered in syncing SSE events into Pinia stores; the Nuxt-specific point is that the store’s server-rendered state comes from the snapshot fetched during SSR, and the stream only starts once the store is hydrated in the browser.
Validation & Monitoring Permalink to this section
# The Nitro route streams unbuffered in the production build.
npx nuxi build && node .output/server/index.mjs &
curl -sN http://localhost:3000/api/orders/stream | head -5
Load pages with JavaScript disabled to confirm server rendering shows the snapshot; with JavaScript on, confirm no hydration warnings and exactly one stream request. Navigate away and back to check the connection closes and reopens from the latest cursor.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why does my composable run on the server?
setup() runs during server rendering. Create connections inside onMounted, which only runs in the browser, or in a .client plugin.
Is module-level state in a composable safe in Nuxt?
On the client it is per tab and safe for sharing a connection. On the server it is shared across requests, so it must never hold per-user data or connections there.
Can Nitro stream on serverless deployments?
Streaming support and duration limits depend on the deployment preset and host. Plan for handovers with Last-Event-ID on hosts with short limits.
Should I use useFetch for the stream itself?
No. useFetch is for request/response data and SSR payloads. Use EventSource or a fetch-based client in the browser for the stream.