Nuxt.js: Building Full-Stack Vue Apps
Nuxt.js has fundamentally changed how developers build Vue.js applications. While Vue.js gives you a powerful reactive component system, building a production-ready application still requires wiring up routing, SSR, state management, API layers, and build tooling. Nuxt.js solves all of that in a cohesive, opinionated framework that lets you ship faster without sacrificing flexibility. With Nuxt 3 powered by the Nitro server engine and Vite under the hood, you get blazing-fast development and a full-stack platform built on the same codebase.
Major companies including Gitlab, Louis Vuitton, Zadig & Voltaire, and thousands of agencies worldwide use Nuxt.js in production. With over 53,000 GitHub stars and 3 million weekly npm downloads, Nuxt is the definitive full-stack framework for Vue developers.
What is Nuxt.js?
Nuxt.js is a meta-framework built on top of Vue.js that adds everything you need for production applications. Where raw Vue.js handles the UI layer, Nuxt provides the full application stack:
- 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 needed - Auto-Imports: Composables, components, and utilities are automatically imported — no import statements required
- 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
Setting up a new Nuxt.js project is fast thanks to the official nuxi CLI. 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' },
],
},
},
})
One of Nuxt's best features is zero-config auto-imports. Place a composable in composables/useAuth.ts and it's automatically available in any component. Same for components in components/ — just use <MyButton /> without any import statement.
File-Based Routing
Nuxt automatically generates routes based on the file structure inside pages/. This eliminates router configuration entirely and makes the URL structure obvious from 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 — perfect 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 provides composables specifically designed for SSR-aware data fetching. The key difference from plain fetch is that Nuxt's composables run on the server during SSR, serialize the data, and then hydrate it on the client — so you never double-fetch.
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 — 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)),
// Cache for 60 seconds
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 — causing a double request and a flash of empty content. Always use useFetch or useAsyncData for SSR-compatible data fetching.
Server Routes and API Endpoints
One of Nuxt's most powerful features is the ability to write full backend API endpoints in the same project. Files in server/api/ are automatically exposed as API routes, handled by the Nitro server engine. This means you can access your database directly without 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
Nuxt provides two layers of state management: the built-in useState composable for simple shared state, and full Pinia integration for complex applications.
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 }
})
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 multiple rendering strategies that you can even mix per-route using route rules. Understanding when to use each mode is essential for building performant applications.
| 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
Nuxt compiles to different output formats depending on your deployment target. Nitro's universal output means the same codebase deploys to any platform without code changes.
# 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 automatically detects the deployment environment on Vercel, Netlify, and Cloudflare — no build configuration needed. Just connect your Git repo. For Node.js servers, copy the .output/ folder and run node .output/server/index.mjs. Environment variables are read at runtime, so secrets never get baked into the build.
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
Next Steps
You now have a solid understanding of Nuxt.js's core concepts. Here's what to explore to take your skills further:
Continue Learning
- Nuxt Modules: Explore the official module ecosystem —
@nuxt/image,@nuxtjs/i18n,@nuxt/content,nuxt-auth-utils - Nuxt Content: Build a full CMS-powered blog with Git-based content and MDC syntax
- Drizzle or Prisma: Pair server routes with a type-safe ORM for database access
- NuxtHub: Deploy full-stack Nuxt apps to Cloudflare with D1 database and R2 storage
- nuxt-auth-utils: Drop-in OAuth authentication with GitHub, Google, and more
- Vitest + @nuxt/test-utils: Write unit and integration tests for your Nuxt app
"Nuxt removes the complexity of building production Vue apps so you can focus on what makes your product unique — not on configuration."
— Sebastien Chopin, Creator of Nuxt.js
Nuxt.js strikes a rare balance: it's opinionated enough to make decisions for you (routing, SSR, auto-imports) while staying flexible enough to escape the defaults when you need to. Whether you're building a simple marketing site or a complex SaaS platform, Nuxt gives you a production-grade foundation from day one. Now pick a project and start building.