Nuxt.js: Building Full-Stack Vue Apps
You start a Vue project with Vite, and a few weeks in you're hand-wiring vue-router, running a separate Express server for the API, and finding out that search engines see an empty <div id="app">. Adding server rendering to that setup after the fact is painful. Nuxt makes those decisions up front: routing, SSR, data fetching, and a backend all live in one repo and one build.
This post walks through a Nuxt 3 project from scaffold to deploy: file-based routing, SSR-aware data fetching, Nitro server routes, state, rendering modes, and the environment variable gotcha that catches a lot of people on their first production deploy.
Yes. Nuxt has been around since 2016, it's one of the most widely used Vue frameworks, and Nuxt 3 has been stable since late 2022. If your team already knows Vue, it's the default choice for anything that needs SSR or SEO.
What Nuxt Adds on Top of Vue
Vue on its own handles components and reactivity. Everything else an app needs in production, you'd normally pick and wire up yourself. Nuxt ships these out of the box:
- Server-Side Rendering (SSR): Pages are rendered on the server for better SEO and initial load performance
- Static Site Generation (SSG): Pre-render every page at build time for edge-deployable static output
- File-Based Routing: Create a file in
pages/and it becomes a route. No router config. - Auto-Imports: Composables, components, and utilities are imported for you, so you skip the import statements
- Server API Routes: Write backend API endpoints in
server/api/alongside your frontend code - Nitro Server Engine: A universal, portable server that deploys to Node.js, Cloudflare Workers, Vercel, Netlify, and more
- TypeScript First: Full TypeScript support out of the box, including auto-generated types for your API routes
Nuxt.js full-stack architecture, from browser request to database
Getting Started with Nuxt.js
The official nuxi CLI scaffolds a project in under a minute. You'll need Node.js 18 or later.
Create a New Nuxt Project
Use the official nuxi CLI to scaffold a new Nuxt 3 application with a single command.
# Create a new Nuxt 3 project
npx nuxi@latest init my-nuxt-app
# Navigate into the project
cd my-nuxt-app
# Install dependencies
npm install
# Start the development server
npm run dev
Understand the Project Structure
Nuxt uses a convention-over-configuration approach. Each directory has a specific purpose that Nuxt understands automatically.
my-nuxt-app/
├── pages/ # File-based routing (auto-creates routes)
│ ├── index.vue # → /
│ ├── about.vue # → /about
│ └── blog/
│ ├── index.vue # → /blog
│ └── [slug].vue # → /blog/:slug (dynamic)
├── components/ # Vue components (auto-imported)
├── composables/ # Reusable composables (auto-imported)
├── layouts/ # App layouts (default.vue, etc.)
├── server/
│ ├── api/ # API endpoints (server/api/users.get.ts)
│ └── middleware/ # Server middleware
├── assets/ # CSS, images, fonts (processed by Vite)
├── public/ # Static files served as-is
├── plugins/ # Nuxt plugins (run at startup)
├── middleware/ # Route middleware (auth guards, etc.)
├── nuxt.config.ts # Nuxt configuration
└── app.vue # Root app component
Configure nuxt.config.ts
The Nuxt config file lets you set runtime config, modules, CSS imports, and build options in one place.
export default defineNuxtConfig({
// Enable devtools for debugging
devtools: { enabled: true },
// Global CSS
css: ['~/assets/css/main.css'],
// Runtime config (server-only vs public)
runtimeConfig: {
// Server-only (not exposed to client)
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
// Public config (accessible on client)
public: {
apiBase: process.env.API_BASE_URL || '/api',
appName: 'My Nuxt App',
},
},
// Install Nuxt modules
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@nuxt/image',
],
// App-level head config
app: {
head: {
title: 'My Nuxt App',
meta: [
{ name: 'description', content: 'A full-stack Nuxt.js application' },
],
},
},
})
Put a composable in composables/useAuth.ts and you can call it from any component. Components in components/ work the same way: write <MyButton /> with no import. One catch: only top-level files in composables/ are scanned, so a composable in composables/auth/useAuth.ts won't be picked up unless you add that folder to imports.dirs. If your editor says a composable is undefined, restart the dev server so .nuxt/ types get regenerated.
File-Based Routing
Routes come from the file structure inside pages/. There's no router config to maintain, and you can read the URL structure straight off the file tree.
Every .vue file in pages/ becomes a route automatically:
<!-- pages/index.vue → / -->
<template>
<div>
<h1>Welcome Home</h1>
<NuxtLink to="/about">About Us</NuxtLink>
</div>
</template>
<!-- pages/about.vue → /about -->
<template>
<div>
<h1>About Page</h1>
<NuxtLink to="/">Back Home</NuxtLink>
</div>
</template>
<!-- pages/blog/index.vue → /blog -->
<template>
<div>
<h1>Blog List</h1>
</div>
</template>
Wrap a filename segment in square brackets to make it dynamic:
<!-- pages/blog/[slug].vue → /blog/:slug -->
<template>
<article>
<h1>{{ post?.title }}</h1>
<p>{{ post?.body }}</p>
</article>
</template>
<script setup lang="ts">
const route = useRoute()
// route.params.slug is automatically typed
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
</script>
<!-- pages/users/[id]/posts/[postId].vue -->
<!-- → /users/:id/posts/:postId -->
<script setup>
const { id, postId } = useRoute().params
</script>
<!-- Catch-all: pages/[...slug].vue -->
<!-- Matches /anything/nested/deeply -->
Layouts wrap pages with shared structure like navbars and footers:
<!-- layouts/default.vue (applied to all pages) -->
<template>
<div>
<AppNavbar />
<main>
<slot /> <!-- Page content goes here -->
</main>
<AppFooter />
</div>
</template>
<!-- layouts/dashboard.vue (custom layout) -->
<template>
<div class="dashboard-shell">
<DashboardSidebar />
<div class="content">
<slot />
</div>
</div>
</template>
<!-- pages/dashboard/index.vue - use custom layout -->
<script setup>
definePageMeta({
layout: 'dashboard',
})
</script>
Route middleware runs before navigating to a route, which makes it the usual place for auth guards:
<!-- middleware/auth.ts -->
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated } = useAuth()
if (!isAuthenticated.value) {
return navigateTo('/login')
}
})
<!-- Apply to a specific page -->
<script setup>
definePageMeta({
middleware: 'auth',
})
</script>
<!-- Apply globally (prefix with "global" in filename) -->
<!-- middleware/global-analytics.ts -->
export default defineNuxtRouteMiddleware((to) => {
// runs on every navigation
trackPageView(to.path)
})
Data Fetching in Nuxt
Nuxt's fetching composables differ from plain fetch in one important way: they run on the server during SSR, serialize the result into the page payload, and hand it to the client during hydration. The client doesn't fetch the same data a second time.
useFetch: The Primary Fetching Composable
<template>
<div>
<div v-if="status === 'pending'">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else>
<li v-for="post in posts" :key="post.id">
<NuxtLink :to="`/posts/${post.slug}`">
{{ post.title }}
</NuxtLink>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
interface Post {
id: number
title: string
slug: string
excerpt: string
}
// useFetch runs on server during SSR, then hydrates on client
// Data is serialized and sent with the HTML, so no client re-fetch
const {
data: posts,
status,
error,
refresh,
} = await useFetch<Post[]>('/api/posts', {
// Key for deduplication and cache
key: 'all-posts',
// Transform before storing
transform: (raw) => raw.sort((a, b) => a.title.localeCompare(b.title)),
// Reuse payload data instead of refetching on client navigation
getCachedData: (key, nuxtApp) =>
nuxtApp.payload.data[key] || nuxtApp.static.data[key],
})
</script>
useAsyncData: Full Control Over Fetching Logic
<script setup lang="ts">
const route = useRoute()
// useAsyncData lets you run any async function, not just fetch
const { data: post } = await useAsyncData(
`post-${route.params.slug}`,
async () => {
// Can call multiple APIs, query a DB directly, etc.
const [postData, relatedPosts] = await Promise.all([
$fetch(`/api/posts/${route.params.slug}`),
$fetch(`/api/posts/${route.params.slug}/related`),
])
return { ...postData, related: relatedPosts }
}
)
// Reactive SEO: updates <head> based on fetched data
useSeoMeta({
title: () => post.value?.title,
description: () => post.value?.excerpt,
ogTitle: () => `${post.value?.title} | My Blog`,
})
</script>
Using the native fetch() inside a component setup() won't be SSR-aware. The request will fire on the server, the component renders, the data is discarded, and then fetch() fires again on the client. You get a double request and a flash of empty content. Use useFetch or useAsyncData in setup, and plain $fetch inside event handlers like form submits.
Server Routes and API Endpoints
This is the part that makes Nuxt full-stack. Any file in server/api/ becomes an API route, served by the Nitro server engine. For a lot of apps that means you can talk to the database directly and skip running a separate backend service.
import { defineEventHandler, getQuery } from 'h3'
// GET /api/posts
export default defineEventHandler(async (event) => {
const { page = 1, limit = 10, category } = getQuery(event)
// Access runtime config (server-only env vars)
const config = useRuntimeConfig()
// Query your database (db client is auto-imported if configured)
const posts = await db.posts.findMany({
where: category ? { category } : {},
orderBy: { createdAt: 'desc' },
take: Number(limit),
skip: (Number(page) - 1) * Number(limit),
select: {
id: true,
title: true,
slug: true,
excerpt: true,
createdAt: true,
},
})
return { posts, page: Number(page) }
})
import { defineEventHandler, readBody, createError } from 'h3'
// POST /api/posts: same file prefix, different HTTP method suffix
export default defineEventHandler(async (event) => {
// Ensure the user is authenticated
const session = await getUserSession(event)
if (!session?.user) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const body = await readBody(event)
// Validate input
if (!body.title || !body.content) {
throw createError({
statusCode: 422,
message: 'Title and content are required',
})
}
const post = await db.posts.create({
data: {
title: body.title,
slug: slugify(body.title),
content: body.content,
authorId: session.user.id,
},
})
// Set 201 Created status
setResponseStatus(event, 201)
return post
})
Server API File Naming Convention
| File | HTTP Method | Route |
|---|---|---|
server/api/posts/index.get.ts |
GET | /api/posts |
server/api/posts/index.post.ts |
POST | /api/posts |
server/api/posts/[id].get.ts |
GET | /api/posts/:id |
server/api/posts/[id].put.ts |
PUT | /api/posts/:id |
server/api/posts/[id].delete.ts |
DELETE | /api/posts/:id |
server/api/posts/index.ts |
ALL methods | /api/posts |
How Nuxt routes API requests through Nitro to your handlers
State Management
You get two options. The built-in useState composable covers simple shared state, and Pinia (via @pinia/nuxt) is there when you want stores with getters, actions, and devtools support.
Don't use a plain ref() declared at module level in a composable for shared state. On the server, that module is shared across every request, so one user's data can leak into another user's rendered page. useState and Pinia are both scoped per request, which is why they exist.
useState: SSR-Safe Shared State
// useState is SSR-safe: the same state is shared between
// server and client during hydration (no mismatch)
export const useCounter = () => {
const count = useState('counter', () => 0)
return {
count,
increment: () => count.value++,
decrement: () => count.value--,
reset: () => count.value = 0,
}
}
// Usage in any component, no imports needed:
// <script setup>
// const { count, increment } = useCounter()
// </script>
Pinia: Full State Management
import { defineStore } from 'pinia'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(null)
// Getters
const isAuthenticated = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
// Actions
async function login(email: string, password: string) {
const { data } = await useFetch('/api/auth/login', {
method: 'POST',
body: { email, password },
})
if (data.value) {
user.value = data.value.user
token.value = data.value.token
}
}
function logout() {
user.value = null
token.value = null
navigateTo('/login')
}
return { user, token, isAuthenticated, isAdmin, login, logout }
})
The login action above calls useFetch, which is fine if it's only ever called from setup. If you call it from a button click handler, Nuxt warns that a composable was called outside setup, and you can get the "[nuxt] instance unavailable" error. For actions that run on user events, swap it for await $fetch('/api/auth/login', { method: 'POST', body }), which returns the data directly.
Useful Nuxt Built-in Composables
| Composable | Purpose |
|---|---|
useRoute() |
Access current route params, query, path |
useRouter() |
Navigate programmatically |
useFetch() |
SSR-aware data fetching with caching |
useAsyncData() |
Run any async function with SSR support |
useState() |
SSR-safe shared reactive state |
useSeoMeta() |
Reactive head/meta tag management |
useRuntimeConfig() |
Access runtime config variables |
useNuxtApp() |
Access the Nuxt app instance and plugins |
useHead() |
Manipulate the document head |
navigateTo() |
Redirect inside middleware or setup |
Rendering Modes
Nuxt supports several rendering strategies, and you can mix them per route with routeRules. Picking the wrong one is a common cause of slow pages or stale content, so it's worth deciding per section of the site.
| Mode | How It Works | Best For | Config |
|---|---|---|---|
| SSR | Server renders HTML on each request | Dynamic content, personalized pages, real-time data | ssr: true (default) |
| SSG | All pages pre-rendered at build time | Marketing sites, docs, blogs with infrequent updates | nuxt generate |
| SPA | Single page, JS renders everything client-side | Admin dashboards, apps behind auth, internal tools | ssr: false |
| ISR / Hybrid | Per-route config: cache SSR pages like SSG | Large sites wanting SSG performance + fresh data | routeRules |
export default defineNuxtConfig({
routeRules: {
// Homepage: pre-render at build time (SSG)
'/': { prerender: true },
// Blog listing: regenerate every 60 seconds (ISR-like)
'/blog': { swr: 60 },
// Individual blog posts: cache for 1 hour
'/blog/**': { swr: 3600 },
// Admin area: no SSR, SPA mode, requires auth
'/admin/**': { ssr: false },
// API routes: set CORS headers
'/api/**': {
cors: true,
headers: { 'cache-control': 's-maxage=0' },
},
// Legacy URL redirect
'/old-blog/**': { redirect: '/blog/**' },
},
})
Building and Deploying
The build output depends on your deployment target. Nitro has presets for most hosts, so the same codebase can go to a Node server, a serverless platform, or an edge runtime without code changes (as long as your server code doesn't rely on Node-only APIs when targeting the edge).
# Production SSR build (Node.js server)
npm run build
# Output: .output/ directory with server/ and public/
node .output/server/index.mjs
# Static site generation (no server needed)
npm run generate
# Output: .output/public/ (deploy to any CDN)
# Preview production build locally
npm run preview
# Build for specific presets
NITRO_PRESET=vercel npm run build # Vercel Edge Functions
NITRO_PRESET=cloudflare npm run build # Cloudflare Workers
NITRO_PRESET=netlify npm run build # Netlify Functions
NITRO_PRESET=aws-lambda npm run build # AWS Lambda
Nuxt detects Vercel, Netlify, and Cloudflare at build time, so on those platforms you usually just connect the Git repo. For a plain Node.js server, copy the .output/ folder and run node .output/server/index.mjs. You don't need node_modules on the server; the output is self-contained.
Here's the gotcha I mentioned at the top. In nuxt.config.ts, process.env.DATABASE_URL is read when you build, not when the server starts. If the variable isn't set in your CI build, the value is empty, and setting DATABASE_URL on the production server later won't change it. At runtime, Nuxt only overrides runtimeConfig from variables with the NUXT_ prefix: NUXT_DATABASE_URL for databaseUrl, NUXT_PUBLIC_API_BASE for public.apiBase. Use those names in production and you can build once and deploy the same artifact anywhere.
Environment Variables Best Practices
# Server-only secrets (map to runtimeConfig keys)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=your-super-secret-key
STRIPE_SECRET_KEY=sk_test_xxx
# Public variables (map to runtimeConfig.public keys)
NUXT_PUBLIC_API_BASE=https://api.example.com
NUXT_PUBLIC_APP_NAME=My App
When I'd Use Nuxt (and When I Wouldn't)
I'd pick Nuxt for any Vue project where SEO or first-load speed matters: content sites, e-commerce storefronts, marketing pages with a logged-in area, blogs. The server routes are also handy for the "backend for frontend" layer, like proxying a third-party API so its key never reaches the browser, or shaping data from a CMS.
I'd think twice in a few cases:
- Internal dashboards behind a login. Nobody needs SSR there. Plain Vue with Vite is simpler, and you avoid hydration mismatch bugs entirely. (Nuxt with
ssr: falseworks too, but then you're mostly paying for the conventions.) - A heavy backend. Nitro routes are fine for CRUD and glue code. Queues, scheduled jobs, complex authorization, and long-running work are better in a dedicated backend like Laravel or a Node service, with Nuxt as the frontend.
- Teams that dislike magic. Auto-imports and directory conventions are fast once you know them, but they make it harder for newcomers to see where something comes from. Expect some ramp-up.
- Lots of browser-only libraries. Anything that touches
windowat import time will crash during SSR with "window is not defined". You'll be wrapping things in<ClientOnly>or.client.tsplugins, which is manageable but adds friction.
Modules Worth Adding When You Need Them
- Official modules:
@nuxt/image,@nuxtjs/i18n,@nuxt/content,nuxt-auth-utils - Nuxt Content: a Git-based CMS for blogs and docs, with MDC syntax
- Drizzle or Prisma: a type-safe ORM for your server routes
- NuxtHub: deploys full-stack Nuxt apps to Cloudflare with D1 database and R2 storage
- nuxt-auth-utils: OAuth login with GitHub, Google, and others, plus sealed cookie sessions
- Vitest + @nuxt/test-utils: unit and integration tests that run in a Nuxt environment
Before your first production deploy, check three things: runtime secrets use the NUXT_ prefix, nothing secret sits under runtimeConfig.public, and no composable keeps shared state in a module-level ref. Those cover most of the Nuxt problems that only show up once real traffic hits the server.