Nuxt 4.5 launched last month and it's really neat. One of my most favorite features is the experimental SSR streaming. With one simple change in the Nuxt config I was able to reduce the Largest Contentful Paint (LCP) by over 2 seconds. I simply enabled the experimental SSR streaming, and the first byte arrived in 16 milliseconds.
With out it, the server spent about 2.5 seconds rendering the page. Streaming changed what the browser could display during that wait.
As a part of my testing I also tried out two other features: useLayout() and named views. Let's take a look at all of these now!
This post walks through all three features using Nuxt 4.5.2. Let's take a look!
If you rather watch check out my full video here
Prerequisites
You will need:
- Node.js 22 or another version supported by Nuxt 4.5
- npm
- Basic experience with Nuxt pages, components, and layouts
- Chrome or another browser with document-request timing tools
- My sample app! https://github.com/ErikCH/nuxt-ssr-streaming
After cloning down the repo, build it and run it in preview.
npm ci
npm run typecheck
npm run build
npm run preview
The demo preview runs on port 3010. The streamed route is /, and /buffered uses the same components with streaming disabled by a route rule.
Feature 1: Experimental SSR streaming
Normal server-side rendering can buffer the document until every server-rendered component finishes. A slow database query or API request can leave the browser waiting even when the page shell is ready.
Nuxt 4.5 added the experimental ssrStreaming option. It uses Vue's web-stream renderer to flush ready HTML while later boundaries continue rendering.
Create a controlled slow component
I used a fixed delay so the delivery difference would be easy to see. The delay runs on the server, and useState serializes the result into the Nuxt payload for hydration.
<script setup lang="ts">
interface SlowResult {
resolvedAt: string
serverDurationMs: number
renderMarker: string
}
const props = withDefaults(
defineProps<{
delayMs?: number
}>(),
{
delayMs: 2500,
},
)
const route = useRoute()
const result = useState<SlowResult>(`slow-panel:${route.path}`, () => ({
resolvedAt: '',
serverDurationMs: 0,
renderMarker: '',
}))
if (import.meta.server) {
const startedAt = Date.now()
await new Promise(resolve => setTimeout(resolve, props.delayMs))
result.value = {
resolvedAt: new Date().toISOString(),
serverDurationMs: Date.now() - startedAt,
renderMarker: Date.now().toString(36).slice(-6).toUpperCase(),
}
}
</script>
Place the component inside a Vue Suspense boundary. The fallback becomes part of the shell that Nuxt can send before SlowPanel resolves.
<template>
<main>
<header>
<p>Nuxt 4.5 rendering test</p>
<h1>The shell is ready</h1>
</header>
<Suspense>
<SlowPanel :delay-ms="2500" />
<template #fallback>
<article aria-live="polite" aria-busy="true">
<p>Shell received</p>
<h2>Waiting for the slow server boundary...</h2>
</article>
</template>
</Suspense>
</main>
</template>
The fallback needs to be useful. Navigation, page context, and a clear loading state give the visitor something to work with. Sending an empty shell earlier does little for the experience.
Enable streaming in production
The demo enables streaming outside development and keeps /buffered as a control route.
const ssrStreamingEnabled = process.env.NODE_ENV !== 'development'
export default defineNuxtConfig({
compatibilityDate: '2026-07-01',
features: {
devLogs: false,
},
experimental: {
ssrStreaming: ssrStreamingEnabled,
},
runtimeConfig: {
public: {
ssrStreamingEnabled,
},
},
routeRules: {
'/buffered': { streaming: false },
},
})
The /buffered route uses the same slow component, delay, and shell. Its route rule changes the response mode without changing the work performed by the component.
Compare the responses
Open the streamed and buffered routes as full document requests. Disable the browser cache and use a hard reload. Client-side navigation does not create a new SSR document request.
You can also measure the response chunks from a second terminal:
npm run measure
My local production preview produced this result:
streamed: first byte 16 ms, complete 2562 ms, 14 chunks, 5.8 KB
buffered: first byte 2518 ms, complete 2518 ms, 1 chunk, 5.8 KB
Both routes performed the same delayed work and returned the same amount of HTML. The streamed route delivered the response in 14 chunks and gave the browser useful HTML much earlier. The buffered route delivered one chunk after the slow boundary finished.
This was just a test, on a somewhat contrived setup. However, I would try this yourself in your own app and see how the results go. Make sure to try it in development first, before going to production.
Check the production constraints
ssrStreaming is experimental and disabled by default. Nuxt can fall back to buffered rendering for:
- Bots and crawlers
- Cached routes
- Incremental Static Regeneration (ISR)
- Stale-while-revalidate (SWR) routes
- Redirects
- Routes using
ssr: false - Prerendered output
- Routes with
streaming: false
Streaming also changes when the HTTP response becomes committed. After the shell has been flushed, later component code may be too late to change the status, headers, or cookies. Test authentication redirects, cookie writes, cache headers, errors, and middleware before enabling streaming on a production route.
I would start with a content-heavy page that has a useful shell and one isolated slow boundary. Measure the deployed route with its real adapter and route rules. Keep buffering where the application needs to finish response decisions before sending HTML.
Feature 2: Read the resolved layout with useLayout()
Nuxt 4.5 added a stable useLayout() composable. It returns a read-only computed ref containing the layout Nuxt resolved for the current route.
<script setup lang="ts">
const layout = useLayout()
</script>
<template>
<output class="layout-badge">
<code>useLayout()</code>
<strong>{{ layout === false ? 'disabled' : layout }}</strong>
</output>
</template>
The value accounts for page metadata, route-rule layout selection, and the default layout. It also updates during navigation.
In the demo, two child pages select different layouts:
<!-- app/pages/features/overview.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'default',
})
</script>
<!-- app/pages/features/focus.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'focus',
})
</script>
The parent page stays mounted while navigation changes the child route. The computed layout updates from default to focus without requiring the parent to inspect route metadata itself.
Reading route.meta.layout can miss layouts selected elsewhere in Nuxt's resolution chain. Use useLayout() when a component needs the layout Nuxt is actually rendering.
Feature 3: Render multiple outlets with named views
Named views let one route render into multiple page outlets. Nuxt 4.5 uses a name@view.vue filename convention for the additional view files.
The demo uses this structure:
app/pages/
features.vue
features/
index.vue
overview.vue
overview@sidebar.vue
focus.vue
focus@sidebar.vue
The parent page creates a default outlet and a named sidebar outlet:
<template>
<section aria-label="Named view outlets">
<article>
<NuxtPage />
</article>
<aside>
<NuxtPage name="sidebar" />
</aside>
</section>
</template>
When you visit /features/overview, overview.vue fills the default outlet and overview@sidebar.vue fills the sidebar. The focus files do the same work for /features/focus. Both outlets belong to one URL, and the parent does not need a manual component map.
Keep route metadata in the default page file. Nuxt ignores definePageMeta in an @sidebar file. If a route has no matching named-view file, that outlet remains empty.
Finale
I am really impressed by the incremental changes in Nuxt 4.5. SSR streaming is something I'll be using on all my future apps. And useLayout and named views are nice DX bonuses.
Leave a comment and let me know which of these features you'll be using next!
Top comments (1)
What do you like about Nuxt?