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 defined breaks 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.

The Nuxt SSE loop end to end Flow from a Nitro server route streaming events, through useFetch loading a snapshot during SSR, a client-only composable opening the stream on mount, and reactive state updating the page. The Nuxt SSE loop end to end Nitro route text/event-stream serves useFetch snapshot in SSR payload Hydrated page same state mount onMounted open EventSource events Reactive state deltas applied
The snapshot travels in the SSR payload; the stream only exists in the browser. Both come from the same Nitro server.

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.

The composable's shared connection State diagram of the shared connection in the composable moving from closed to open on the first mount, staying open while mounts remain, and closing on the last unmount. The composable's shared connection CLOSED users 0 OPEN users ≥ 1 LAST UNMOUNT users → 0 first onMounted onBeforeUnmount es.close()
Mount and unmount hooks count users. Server rendering never enters this machine, because the hooks only run in the browser.

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.

Stream connections on a page with four live components Bar chart comparing stream connections opened by a Nuxt page with four live components when each component opens its own EventSource versus when they share one through the composable. Stream connections on a page with four live components EventSource per component 4 Shared composable 1 open stream connections for one tab
Sharing via the composable turns four connections into one without changing any component.

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.