EmpireUI
Get Pro
← Blog9 min read#nuxt3#vue#ssr

Nuxt 3 Guide 2026: SSR, Composables, Nitro and Auto-Imports

Everything you actually need to know about Nuxt 3 in 2026 — SSR hydration, Nitro server routes, composables, and the auto-import system that trips everyone up.

Green code terminal with Vue framework syntax on dark background

Why Nuxt 3 Is Still Worth Learning in 2026

Vue's had a rough marketing year, but Nuxt 3 quietly became one of the most well-engineered meta-frameworks available. The Nitro server engine, the auto-import system, and the composables model have all matured. You're not picking up a framework that's still finding itself.

In practice, the main reason people bounce off Nuxt 3 is the magic. Auto-imports feel like sorcery when you first encounter them — you write useAsyncData() in a component with zero import statement and it just works. That can feel uncomfortable if you're used to explicit dependency graphs. But once you understand how it works, you'd never go back.

The framework hit Nuxt 3.12 in early 2026, and that release solidified a lot of the patterns that were previously considered experimental — particularly around server components and streaming. If you looked at Nuxt 3 in 2023 and walked away, it's genuinely worth another look.

That said, this guide isn't a Hello World walkthrough. It's for developers who already know Vue 3 Composition API and want to understand how Nuxt layers on top of it — specifically where it diverges from a plain Vite + Vue setup and why those divergences matter.

SSR Hydration: What Actually Happens

The SSR model in Nuxt 3 is server-renders-HTML, client-rehydrates-DOM. But the interesting part is *how* state is transferred. Nuxt serializes your reactive state into a __NUXT_DATA__ payload embedded in the HTML. The client picks that up and skips re-fetching the same data on first load. This is the mechanism behind useAsyncData and useFetch — they deduplicate between server and client automatically.

Here's the minimal pattern you'll use constantly: ``vue <script setup lang="ts"> const { data: posts } = await useAsyncData('posts', () => $fetch('/api/posts') ) </script> <template> <ul> <li v-for="post in posts" :key="post.id">{{ post.title }}</li> </ul> </template> ` The key 'posts'` is your deduplication key. Use the same key in two different components on the same page? They'll share one fetch. Different key? Two fetches. That distinction trips people up endlessly.

Hydration mismatches are the classic SSR headache. In Nuxt 3, the most common cause is accessing window or document during setup. The server doesn't have those. Use onMounted for browser-only code, or check process.client if you need to branch earlier. Honestly, just defaulting to onMounted for anything DOM-related will save you hours of debugging.

Worth noting: Nuxt 3 ships with <ClientOnly> as a built-in component. Wrap anything that truly can't run on the server — canvas elements, WebGL, libraries that read navigator — and you're done. No third-party workaround needed.

One more thing — useHydration() was added in 3.10 and gives you fine-grained control over what state gets serialized into that __NUXT_DATA__ payload. If you're building something with large initial state (think a data-heavy dashboard), understanding this composable can meaningfully reduce your initial HTML payload size.

The Nitro Server Layer

Nitro is the server engine that powers Nuxt 3, but it's also a standalone framework you can use separately. Inside a Nuxt project, it handles your API routes, server middleware, and deployment adapters. The file-based routing in server/ mirrors how pages/ works on the client side.

API routes live in server/api/ and are trivially simple to write: ``ts // server/api/components.get.ts export default defineEventHandler(async (event) => { const query = getQuery(event) const page = Number(query.page) ?? 1 const components = await db.components .findMany({ skip: (page - 1) * 20, take: 20 }) return components }) ` The filename suffix (.get.ts, .post.ts) maps to HTTP methods. No router configuration, no express middleware chain to thread through. It deploys as a standalone server, Vercel edge functions, Cloudflare Workers, or a Docker container — you pick the adapter in nuxt.config.ts`.

Server middleware sits in server/middleware/ and runs on every request before route handlers. Use it for auth checks, logging, CORS headers. The pattern is the same defineEventHandler signature — just no return value, or you call sendError() to abort the request chain.

In practice, Nitro's storage layer is the underrated gem here. useStorage() on the server gives you a unified API for Redis, filesystem, or in-memory KV — you configure the driver and your code doesn't change. That's a genuinely useful abstraction when you're shipping to multiple environments.

Look, if you've been using Next.js API routes and felt limited by the edge runtime restrictions, Nitro routes give you more flexibility while still supporting edge deployments when you actually want them. The ergonomics are better for complex server logic.

Composables and the Auto-Import System

Everything in composables/ is auto-imported. Everything in utils/ is auto-imported. Everything in components/ is auto-imported. Nuxt scans those directories at build time and generates type-safe imports via .nuxt/imports.d.ts. You write useFoo() anywhere in your app and TypeScript knows about it — as long as useFoo lives in composables/.

Here's a real composable that you'd actually write: ``ts // composables/useTheme.ts export const useTheme = () => { const theme = useState<'light' | 'dark'>('theme', () => 'dark') const toggle = () => { theme.value = theme.value === 'dark' ? 'light' : 'dark' } return { theme: readonly(theme), toggle } } ` Note useState — that's a Nuxt-specific composable that creates SSR-friendly shared state. If you use plain ref() for shared state, the server and client each get separate instances and they don't sync. useState` is the correct primitive for cross-request shared state in Nuxt.

The auto-import system isn't magic — it's a Vite plugin called unimport that analyzes your code at build time and injects the import statements before compilation. So you get full tree-shaking, correct source maps, and no runtime overhead. The generated .nuxt/ folder is worth reading when something breaks — it shows exactly what got imported where.

Quick aside: you can extend auto-imports in nuxt.config.ts to include your own directories or third-party packages: ``ts export default defineNuxtConfig({ imports: { dirs: ['stores/**', 'lib/**'], presets: [{ from: '@vueuse/core', imports: ['useLocalStorage', 'useDark'] }] } }) `` This is how you get VueUse composables auto-imported without adding the explicit import everywhere.

Where the system bites you: circular dependencies. If composables/useA.ts imports from composables/useB.ts which imports useA, you'll get a runtime error that's hard to trace because the import statements are generated. Keep composables focused on one concern and you'll avoid this.

Data Fetching: useFetch vs useAsyncData

useFetch is syntactic sugar over useAsyncData + $fetch. They're not the same thing, and knowing when to use which matters. useFetch('/api/posts') is the quick path — one line, handles the key generation for you, typed response. useAsyncData is what you reach for when your async operation isn't a simple HTTP call — reading from a store, chaining requests, or needing full control over the key.

// useFetch: great for simple API calls
const { data, pending, error, refresh } = await useFetch<Post[]>('/api/posts', {
  query: { page: currentPage },
  watch: [currentPage],  // re-fetch when currentPage changes
  transform: (posts) => posts.map(p => ({ ...p, date: new Date(p.date) }))
})

// useAsyncData: use when you need custom logic
const { data } = await useAsyncData('user-dashboard', async () => {
  const [user, stats] = await Promise.all([
    $fetch('/api/user'),
    $fetch('/api/stats')
  ])
  return { user, stats }
})

The watch option on useFetch is genuinely useful — it automatically refreshes when reactive refs change. Pair that with lazy: true if you don't want to block navigation on the fetch completing. lazy: true means the page renders immediately with pending === true, then updates when data arrives. It's the right choice for non-critical data.

One pattern worth adopting early: put complex data fetching logic in composables rather than directly in page components. Your pages stay clean and your fetching logic becomes testable and reusable. Something like composables/useBlogPosts.ts that wraps useAsyncData with your specific transform logic is the right level of abstraction.

Nuxt Layers: Sharing Code Across Projects

Nuxt Layers are a 2024-era feature that most developers ignore until they have a specific pain point, then immediately wish they'd known about earlier. A layer is essentially a Nuxt project that another Nuxt project can extend. Your base design system, shared components, composables, and server utilities can all live in a layer and be consumed by multiple apps.

``ts // nuxt.config.ts in a consumer app export default defineNuxtConfig({ extends: [ '@company/ui-layer', // npm package '../shared-layer', // local path ] }) ` Everything in the layer's components/, composables/, pages/, and server/` directories merges with the consuming app. The consuming app can override anything by providing its own file at the same path. It's the right model for a multi-app monorepo where you want shared glassmorphism components or a shared design system without copy-pasting.

In practice, layers work best when the shared code is stable. If you're iterating rapidly on your component library, the extra indirection adds friction. Stabilize the API first, then extract to a layer. Publishing the layer as an npm package (even a private one) gives you proper versioning and the ability to pin consuming apps to specific releases.

The overlap with Turborepo monorepos is worth thinking about. Nuxt layers handle the framework-level sharing (components, composables, server routes). Turborepo handles the build orchestration and caching. They're not in competition — if you're running a multi-app Vue monorepo, you'd typically use both.

Deploying Nuxt 3: Choosing the Right Adapter

Nitro ships with adapters for Vercel, Cloudflare Workers, AWS Lambda, Netlify, and plain Node.js. You set nitro.preset in nuxt.config.ts and the build output changes accordingly. The default preset is node-server which outputs a standalone Node.js server — fine for Docker-based deployments on something like Dokploy.

``ts export default defineNuxtConfig({ nitro: { preset: 'vercel-edge', // or 'cloudflare-pages', 'aws-lambda', etc. routeRules: { '/api/**': { cors: true, headers: { 'cache-control': 's-maxage=60' } }, '/blog/**': { isr: 3600 }, // ISR with 1 hour TTL '/': { prerender: true } } } }) ` routeRules is where you configure caching, ISR, CORS, and redirects at the route level. The isr` option — Incremental Static Regeneration — is Nuxt's answer to the same feature in Next.js. A 3600-second TTL means the page is cached for an hour, then regenerated on the next request after that.

Honestly, the adapter system is one of Nuxt's biggest advantages over a plain Vite setup. You write one codebase and can swap deployment targets without touching application code. That's not true in most frameworks where edge vs Node targets require different patterns.

For UI-heavy projects with lots of static pages, combining prerender: true for static routes with isr for dynamic routes gives you the best of both worlds — sub-50ms static delivery for your templates and landing pages, with fresh data for anything that changes. Worth profiling your specific route mix to find the right cache TTLs rather than applying one setting globally.

FAQ

What's the difference between useFetch and useAsyncData in Nuxt 3?

useFetch is a wrapper around useAsyncData + $fetch that handles key generation automatically — use it for simple HTTP calls. Reach for useAsyncData when you need custom async logic, parallel requests, or explicit control over the deduplication key.

How do I prevent hydration mismatches in Nuxt 3?

Never access window, document, or other browser APIs during component setup — move those to onMounted. Wrap components that truly can't run on the server in Nuxt's built-in <ClientOnly> component.

Can I use Nuxt 3 with a separate backend API?

Yes. Set ssr: false for a pure SPA, or keep SSR enabled and point your useFetch calls at your external API. Nuxt's Nitro server can also act as a proxy layer, handling auth and request transformation before forwarding to your backend.

Is the auto-import system safe for production — does it affect bundle size?

It's tree-shaken at build time. The unimport Vite plugin injects explicit import statements before compilation, so only the composables you actually call end up in the bundle. There's no runtime magic and no dead code penalty.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

SvelteKit Guide 2026: Routing, Load Functions, Forms and DeployRemix Guide 2026: Loaders, Actions, Nested Routes and Error UINuxt 3 vs Next.js in 2026: Vue vs React, DX and DeploymentNext.js App Router in 2026: What's Changed and What Still Trips People Up