NUXT
Frontend

Nuxt.js: Building Full-Stack Vue Apps

Mayur Dabhi
Mayur Dabhi
June 6, 2026
15 min read

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.

Is It Mature Enough?

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:

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

The official nuxi CLI scaffolds a project in under a minute. 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' },
      ],
    },
  },
})
How Auto-Imports Behave

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

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, 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

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. 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.

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

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

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 }
})
useFetch Inside Actions

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
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

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).

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 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

.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

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:

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.

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.