Nuxt 3 in Practice: Routing, Server Routes, and the SSR Data and State Pitfalls
Key takeaways
Nuxt 3 adds file-based routing, server routes, and SSR to Vue 3. Most of it works without configuration; the parts that need understanding are the ones where code runs twice, once on the server and once in the browser: data fetching, shared state, and configuration.
Nuxt 3 brings full-stack capabilities to Vue — file-based routing, server API routes, universal data fetching, and automatic SSR/SSG. This guide covers the core features with a practical blog/app example.
The single idea that explains most of Nuxt’s behavior is universal rendering. On the first request, your page components run on the server, produce HTML, and send it with the data they used serialized into the page. The browser then loads the same components and hydrates them: Vue attaches to the existing HTML instead of rendering from scratch. After that, navigation happens in the browser like a normal single-page app. So the code in <script setup> runs in two very different environments, and features like useFetch and useState exist to make those two runs agree with each other. Most Nuxt bugs are cases where they do not: data fetched twice, state that differs between server and client, or server-only values leaking into the browser.
A note on versions: Nuxt 4 was released in 2025. It moves application code into an app/ directory by default and changes some data-fetching defaults, but the concepts and APIs in this article carry over, and Nuxt 3 projects remain common.
Setup
npx nuxi@latest init my-app
cd my-app
npm install
npm run dev
App runs on http://localhost:3000.
Project Structure
my-app/
pages/ ← file-based routing
components/ ← auto-imported components
composables/ ← auto-imported composables (useX)
server/
api/ ← server API routes
middleware/ ← server middleware
layouts/ ← page layouts
middleware/ ← client/universal route middleware
plugins/ ← Nuxt plugins
public/ ← static assets
nuxt.config.ts
File-Based Routing
Create files in pages/ — Nuxt generates routes automatically:
pages/
index.vue → /
about.vue → /about
blog/
index.vue → /blog
[slug].vue → /blog/:slug
users/
[id]/
index.vue → /users/:id
posts.vue → /users/:id/posts
[...404].vue → catch-all / 404
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
// Pass a function (or computed) so the URL stays reactive
const { data: post } = await useFetch(() => `/api/posts/${route.params.slug}`)
</script>
<template>
<article>
<h1>{{ post?.title }}</h1>
<p>{{ post?.body }}</p>
</article>
</template>
The function form of the URL matters more than it looks. With a plain template string, `/api/posts/${route.params.slug}` is evaluated once, when setup runs. If the user navigates from /blog/a to /blog/b, Nuxt often reuses the same page component instead of creating a new one, so setup does not run again. The URL stays /api/posts/a, and the page keeps showing the first post under the second post’s URL. Passing a getter function (or a computed) makes the URL reactive: useFetch watches it and refetches when the slug changes.
This is one of the most common bugs I see in Nuxt code, precisely because it is invisible in testing: opening each post by URL works perfectly, and the problem only appears when navigating between two pages that use the same route, such as “next post” links or related-post lists.
Server Routes (API)
Create files in server/api/ — Nuxt generates server endpoints:
// server/api/posts/index.get.ts
export default defineEventHandler(async (event) => {
// Access DB, external API, etc.
return [
{ id: 1, title: 'Nuxt 3 Guide', slug: 'nuxt-3-guide' },
{ id: 2, title: 'Vue Composables', slug: 'vue-composables' },
]
})
// server/api/posts/[slug].get.ts
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
// Fetch from DB...
return { id: 1, title: 'Nuxt 3 Guide', slug, body: '...' }
})
// server/api/posts/index.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Validate and save...
return { created: body }
})
File naming convention: [name].[method].ts — GET, POST, PUT, DELETE, PATCH.
Server routes run on Nitro, Nuxt’s server engine, and are only ever executed on the server, so this is where database access, secrets, and third-party API keys belong. Two things are easy to overlook. First, readBody returns whatever the client sent, untyped and unvalidated. The // Validate and save... comment is where most security bugs live, so validate with a schema (for example readValidatedBody(event, schema.parse) with Zod) before using it. Second, when a page calls one of its own server routes during SSR, Nuxt calls the handler directly in the same process instead of making an HTTP request, which keeps SSR fast. The flip side is that anything the route reads from the incoming request, such as cookies and headers, is not forwarded automatically when you call it with $fetch on the server. Use useRequestFetch() or useFetch (which forwards them) when the route depends on the user’s session.
Data Fetching
<script setup lang="ts">
// useFetch: SSR-aware, cached, auto-typed
const { data, pending, error, refresh } = await useFetch('/api/posts')
// $fetch: manual fetch (works client and server side)
const post = await $fetch('/api/posts/my-slug')
// useAsyncData: custom async logic
const { data: user } = await useAsyncData('user', () =>
$fetch(`/api/users/${userId}`)
)
// lazy loading (don't block navigation)
const { data: comments, pending } = useLazyFetch('/api/comments')
</script>
<template>
<div>
<div v-if="pending">Loading...</div>
<ul v-else>
<li v-for="post in data" :key="post.id">{{ post.title }}</li>
</ul>
</div>
</template>
useFetch automatically shares data between server and client, so there is no duplicate request. That guarantee applies to useFetch and useAsyncData only, and the difference between them and $fetch is the most important thing on this page.
When useFetch runs during SSR, it stores the result in the Nuxt payload that is serialized into the HTML. During hydration in the browser, it finds the result in the payload and does not fetch again. Plain $fetch in <script setup>, as in the second line of the example, has no such mechanism: it runs on the server during SSR, and again in the browser during hydration. Every page view makes two requests, and if the two responses differ (a timestamp, a random order), Vue reports a hydration mismatch. Use $fetch in event handlers and in actions triggered by the user, such as submitting a form, and use useFetch / useAsyncData for data the page needs to render.
The useAsyncData('user', ...) call has a subtler problem: its key. The key identifies the data in the payload and in Nuxt’s cache. A fixed key like 'user' with a URL that depends on userId means two different users’ data share one cache entry, and a page can show the previous user’s data. Include the parameters in the key (`user-${userId}`), or let useFetch generate the key from the URL and options, which it does automatically. Also note that newer Nuxt 3 versions expose a status field ('idle' | 'pending' | 'success' | 'error') alongside pending, which is clearer for distinguishing “not started” from “loading”.
Composables
Composables in composables/ are auto-imported anywhere in your app:
// composables/useAuth.ts
export function useAuth() {
const user = useState<User | null>('auth-user', () => null)
const isLoggedIn = computed(() => user.value !== null)
async function login(email: string, password: string) {
const data = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password },
})
user.value = data.user
await navigateTo('/dashboard')
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
user.value = null
await navigateTo('/login')
}
return { user, isLoggedIn, login, logout }
}
<script setup lang="ts">
// No import needed — auto-imported
const { user, isLoggedIn, logout } = useAuth()
</script>
Note that user is created with useState, not with a plain ref at the top of the file. This distinction is critical on the server. A module-level const user = ref(null) in a composable file is created once per server process and shared by every request that process handles. With SSR, that means one user’s data can appear in another user’s rendered page: a genuine data leak, and one that never shows up in local development with a single user. useState creates state scoped to the current request on the server, serializes it into the payload, and restores it on the client. The rule is simple: never keep request-specific state in module scope in anything that runs during SSR.
There is still a gap in this composable: after a full page reload, user starts as null again, because nothing restores it from the session. Real authentication reads the session cookie on the server (in a server route or plugin) and fills the state during SSR. Otherwise, the auth middleware below redirects a logged-in user to /login on every reload. Modules such as nuxt-auth-utils handle this wiring.
Layouts
<!-- layouts/default.vue -->
<template>
<div>
<AppHeader />
<main>
<slot /> <!-- page content goes here -->
</main>
<AppFooter />
</div>
</template>
<!-- layouts/dashboard.vue -->
<template>
<div class="dashboard">
<Sidebar />
<main><slot /></main>
</div>
</template>
<!-- pages/dashboard/index.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'dashboard' })
</script>
Middleware
// middleware/auth.ts (client + server)
export default defineNuxtRouteMiddleware((to, from) => {
const { isLoggedIn } = useAuth()
if (!isLoggedIn.value && to.path !== '/login') {
return navigateTo('/login')
}
})
<!-- pages/dashboard/index.vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
</script>
Route middleware runs before navigation, on the server for the first request and in the browser afterwards. That makes it a good place for redirects, but not for security. Middleware only controls which page is rendered. The data behind the page comes from server routes, and anyone can call /api/... directly without going through your pages. Every server route that returns private data must check the session itself.
Server middleware (runs on every request):
// server/middleware/logger.ts
export default defineEventHandler((event) => {
console.log(`[${new Date().toISOString()}] ${event.method} ${event.path}`)
})
Server middleware runs before every server request, including API routes and asset requests handled by Nitro. It should not return a value unless it wants to end the request, and it should stay fast, since it runs for everything. It is a good place for logging, adding context to event.context, or setting headers.
State Management
Nuxt’s useState shares state between server and client:
// composables/useCounter.ts
export const useCounter = () => useState('counter', () => 0)
<script setup lang="ts">
const count = useCounter()
</script>
<template>
<button @click="count++">Count: {{ count }}</button>
</template>
For complex state, use Pinia (Nuxt’s recommended store):
npx nuxi module add pinia
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((sum, i) => sum + i.price, 0))
function addItem(item: CartItem) {
items.value.push(item)
}
return { items, total, addItem }
})
useState is enough for a few shared values. Pinia is worth adding when state has actions, derived values, and several components modifying it, and for its devtools support. Its Nuxt module handles the SSR details (request-scoped stores and payload serialization) for you. Both have the same constraint: state is serialized into the HTML, so it must be JSON-friendly. Class instances, functions, Maps, and Date objects need care or a custom serializer, and large objects make every page heavier.
SEO and Meta
<script setup lang="ts">
useSeoMeta({
title: 'My Page Title',
ogTitle: 'My Page Title',
description: 'This is a description of my page.',
ogDescription: 'This is a description of my page.',
ogImage: 'https://example.com/image.png',
twitterCard: 'summary_large_image',
})
// Dynamic meta
const { data: post } = await useFetch(`/api/posts/${slug}`)
useSeoMeta({
title: () => post.value?.title,
description: () => post.value?.excerpt,
})
</script>
nuxt.config.ts
export default defineNuxtConfig({
// Rendering mode
ssr: true, // SSR (default)
// Nitro server config
nitro: {
preset: 'cloudflare-pages', // or 'vercel', 'netlify', etc.
},
// Runtime config (env vars)
runtimeConfig: {
// Server-only (secret)
databaseUrl: process.env.DATABASE_URL,
// Public (exposed to client)
public: {
apiBase: process.env.API_BASE_URL || '/api',
},
},
// Modules
modules: ['@pinia/nuxt', '@nuxtjs/tailwindcss', '@nuxt/image'],
// TypeScript
typescript: { strict: true },
})
Access runtime config:
const config = useRuntimeConfig()
console.log(config.public.apiBase) // client or server
console.log(config.databaseUrl) // server only
The process.env references in nuxt.config.ts are read at build time. If you build a Docker image once and run it in staging and production with different environment variables, databaseUrl keeps the value from the build machine. Runtime config is designed to be overridden when the server starts, but through specially named variables: NUXT_DATABASE_URL overrides databaseUrl, and NUXT_PUBLIC_API_BASE overrides public.apiBase. The usual pattern is to declare the keys in nuxt.config.ts with empty or default values and set the NUXT_* variables in each environment.
Also keep the split between private and public strictly. Everything under public is serialized into every page’s HTML and readable by anyone. A secret placed there by mistake (an API key needed “only for one client-side call”) is published with every page view.
Deployment
# Build for production
npm run build
# Deploy to Node.js server
node .output/server/index.mjs
# Deploy to Cloudflare Pages (SSR runs in a Pages Function)
NITRO_PRESET=cloudflare-pages npm run build
# Deploy the build output with wrangler, or connect the repo in the Pages dashboard
# Static generation (SSG)
npm run generate
# Upload .output/public anywhere (S3, GitHub Pages, etc.)
npm run build and npm run generate produce very different things. build creates a server that renders pages on each request. generate pre-renders every page it can discover into static HTML at build time, so there is no server, and data is frozen at build time. A hybrid is often the best fit: routeRules in nuxt.config.ts can pre-render marketing pages, cache blog pages for a while (swr: 3600), and render dashboards on every request, all in one app. When deploying to edge runtimes such as Cloudflare Workers, remember that server routes run there too, without Node.js APIs like fs, and database drivers that open raw TCP connections may not work. Check the Nitro preset documentation for your target before choosing libraries.
The same code runs twice
Nuxt 3 removes a lot of boilerplate — auto-imports, file-based routing and built-in SSR — but the price is that most of your page code runs in two places: once on the server to render HTML, and again in the browser to hydrate it. The bugs that are specific to Nuxt almost all come from those two runs disagreeing. Fetch render data with useFetch or useAsyncData rather than a bare $fetch in setup, so the server’s result is transferred instead of requested again; keep per-request state in useState rather than a module-level ref, which would be shared between users on the server; check authorization inside server routes, not only in route middleware; and set configuration at runtime with NUXT_* environment variables instead of baking it in at build time.
Related Articles
- Vue 3: Composition API and reactivity
- SvelteKit: routing, form actions, and load functions
- Next.js App Router: SSR vs SSG vs ISR