•5 min read

Nuxt SSR Streaming: What Breaks After the First Byte

Last week I flipped on Nuxt's SSR streaming flag on our blog routes. TTFB went from embarrassing to something I was happy to screenshot, and for twenty minutes I felt like a genius. Then the 404 page stopped returning 404.

A tall waterfall falling in a thin white column onto mossy rock below a small footbridge

With SSR streaming on, Nuxt flushes the HTML shell as soon as the first render pass finishes: head, entry styles, preload hints, entry scripts. The body then streams as Vue renders it, through Vue's renderToWebStream. The server does the same work either way, so this is a delivery change rather than a cheaper render.

What it does change is the response. Status and headers commit with the first byte, so any later attempt to set a status, write a cookie, or add a header is talking to nobody. The docs say so plainly. I read them after the deploy, not before.

Where the boundary sits

The line is the shell flush, not the start of the request. Mutations from Nuxt and Nitro plugins do reach the client, because plugins run to completion before rendering begins. Dropped are setResponseStatus(), useResponseHeader(), useCookie() writes, and raw h3 setHeader() or appendResponseHeader() calls made while components render. That covers anything in middleware or <script setup> running after an await. Nuxt warns about dropped mutations in development, naming the field and the route, and that warning is how I found the cookie below.

The 404 that came back 200

Our article page looks a post up by slug and calls setResponseStatus(404) when the lookup comes back empty. That could not move into a plugin, because the status depends on data fetched during render, so the route comes off streaming instead:

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: { ssrStreaming: true },
  routeRules: {
    '/posts/**': { streaming: false },
  },
})

The static rule covers most cases. A per-request decision needs the render:route Nitro hook, which fires before rendering begins. ctx.canStream reports whether streaming is possible for the route, and ctx.prefersStream = false forces the buffered renderer for this request. Nuxt streams only when both are true. Field names shift between versions, so check ctx against your build.

// server/plugins/streaming-guard.ts
import { defineNitroPlugin } from '#imports'

export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('render:route', (ctx) => {
    if (ctx.url.includes('preview=')) {
      // preview pages set their own status late, so keep them buffered
      ctx.prefersStream = false
    }
  })
})

The other casualty was an anonymous-visitor flag written in route middleware after an await. Its value did not depend on the render, so moving the write into a Nitro plugin was enough. If it had depended on the render, the route would have had to stop streaming.

The crawler regex trap

Streaming is off for bots by default, so search engines still get fully rendered HTML. Nuxt's pattern covers indexing crawlers, and Lighthouse sits outside it on purpose.

The trap is that botRegex replaces that pattern instead of extending it. A list holding one internal crawler is a list that no longer includes Googlebot.

export default defineNuxtConfig({
  experimental: {
    ssrStreaming: {
      // this REPLACES the default, so list every crawler you want buffered
      botRegex: /googlebot|bingbot|my-internal-crawler/i,
    },
  },
})

Late head tags are the subtler half of the same problem. The renderer waits for the first pass before flushing, so useHead() and useSeoMeta() called synchronously in setup, a plugin, or middleware land in the shell as usual. Tags registered after an await, or inside a nested <Suspense> boundary, ship as inline scripts that patch the DOM on the client. Browsers apply them. A link unfurler, curl, and any crawler missing from your regex never see the tag at all, so keep canonical and social meta synchronous. JSON-LD is the exception: those scripts land as real markup before </body>.

Two flashes of unstyled content

In nuxt dev, Vite serves single file component <style> blocks as JavaScript that injects CSS once the module evaluates. Streaming lets the browser paint the DOM before that injection runs, so styles flash off. Paint-critical CSS belongs in a global stylesheet registered through css, which lands as a <link> in the shell.

Production is narrower. Route CSS is inlined in a chunk right after the shell, but only for modules already registered: the page, the layout, and async components directly inside a <Suspense> boundary. Nest an async component inside another async component and it is instantiated after that chunk has gone out, so its styles arrive near the closing tags and it paints unstyled in between. Tailwind utilities live in the entry stylesheet, which avoids this. Validate with nuxt build and nuxt preview: none of it reproduces in dev.

What I left streaming

Nuxt falls back to the buffered renderer for routes with redirect, cache, isr, or swr rules, ssr: false routes, prerendered pages, and server-side navigateTo() redirects.

I kept it on for article pages and tool landing pages, where the content is the response and nothing mutates it downstream. The image tools under /image/ never needed it: the conversion runs in the browser and the server only ships a shell. Anything that sets a status from fetched data or writes a cookie during render is back on the buffered renderer.

Errors worried me most. They behave better than I expected. A throw after the status is committed sets payload.error but still emits well-formed closing tags, so the client catches the error during hydration and renders the error page. A throw before the flush goes through the buffered error renderer with the right status. The failure mode is an ugly page, not a broken one. If client and server disagree about that markup, read the hydration mismatch post next.

Two weeks in, the streaming routes paint sooner and everything else is unchanged. SSR streaming did not hand me a new class of bug. It removed the polite delay that used to hide one I already had: response logic living too late in the render.