NUXT
Frontend

Nuxt.js: Building Full-Stack Vue Apps

Mayur Dabhi
Mayur Dabhi
June 6, 2026
15 min read

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.

Who Uses Nuxt.js?

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:

Browser Vue Components Hydration Nuxt Server SSR Renderer Vue → HTML Nitro Engine API Routes + Caching pages/ File-based routing server/api/ API endpoints Database / CMS PostgreSQL, Strapi, etc. Deploy Vercel Netlify Request HTML

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.

1

Create a New Nuxt Project

Use the official nuxi CLI to scaffold a new Nuxt 3 application with a single command.

Terminal
# 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
2

Understand the Project Structure

Nuxt uses a convention-over-configuration approach. Each directory has a specific purpose that Nuxt understands automatically.

Project Structure
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
3

Configure nuxt.config.ts

The Nuxt config file lets you set runtime config, modules, CSS imports, and build options in one place.

nuxt.config.ts
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' },
      ],
    },
  },
})
Auto-Import Magic

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

pages/posts/index.vue
<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

pages/posts/[slug].vue
<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>
Avoid Plain fetch() in Components

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.

server/api/posts/index.get.ts
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) }
})
server/api/posts/index.post.ts
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
Client useFetch('/api/posts') Nitro Router Route matching Caching layer Response serialization Event Handler server/api/posts/ index.get.ts DB Prisma GET JSON

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

composables/useCounter.ts
// 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

stores/auth.ts (with @pinia/nuxt)
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
nuxt.config.ts — Hybrid Rendering with 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.

Build Commands
# 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
Zero-Config Deployments

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

.env (development only — never commit to git)
# 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.

Nuxt.js Vue.js Full-Stack SSR JavaScript Nitro Web Development
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.