We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide47 min read

Nuxt

Nuxt is a fullstack Vue framework with SSR, file routing, auto-imports, and advanced module ecosystem.

Nuxt - The Intuitive Vue Framework

Czym jest Nuxt?

Nuxt to potężny fullstack framework oparty na Vue.js, inspirowany architekturą Next.js. Oferuje "batteries-included" podejście do budowania nowoczesnych aplikacji webowych z obsługą Server-Side Rendering (SSR), Static Site Generation (SSG), file-based routing i automatycznymi importami. Nuxt 3, wydany w 2022 roku, został całkowicie przepisany z wykorzystaniem Vue 3 Composition API, TypeScript i nowego silnika serwerowego Nitro.

Framework jest rozwijany przez zespół NuxtLabs i ma bardzo aktywną społeczność z ponad 200 oficjalnymi modułami rozszerzającymi funkcjonalność. Nuxt jest używany przez firmy takie jak GitLab, Upwork, Nintendo i Trivago do budowania wydajnych aplikacji produkcyjnych.

Dlaczego Nuxt?

Kluczowe zalety frameworka

  1. Zero konfiguracji - Działa od razu po instalacji z sensownymi domyślnymi ustawieniami
  2. Auto-imports - Komponenty, composables i utilities są automatycznie importowane
  3. Hybrid Rendering - SSR, SSG, ISR, SPA w jednej aplikacji
  4. Nitro Engine - Uniwersalny serwer działający na edge, serverless i tradycyjnych hostingach
  5. TypeScript first - Pełna obsługa TypeScript bez dodatkowej konfiguracji
  6. Developer Experience - Hot Module Replacement, Vue DevTools, świetne error messages

Nuxt vs Next.js vs SvelteKit

CechaNuxt 3Next.js 14SvelteKit
Framework bazowyVue 3React 18Svelte 4
RenderingSSR/SSG/ISR/SPASSR/SSG/ISR/SPASSR/SSG/SPA
File routingTakTakTak
Auto-importsPełneCzęścioweCzęściowe
Server engineNitroEdge RuntimeAdaptery
TypeScriptNatywnyNatywnyNatywny
Bundle sizeMałyŚredniNajmniejszy
Learning curveŚredniaŚredniaNiska
Ekosystem modułów200+Brak oficjalnych~50
Edge deploymentTakTakTak

Instalacja i konfiguracja

Tworzenie nowego projektu

Code
Bash
# Inicjalizacja projektu
npx nuxi@latest init my-app

# Przejście do katalogu
cd my-app

# Instalacja zależności
npm install

# Uruchomienie dev server
npm run dev

Struktura projektu Nuxt 3

Code
TEXT
my-nuxt-app/
├── .nuxt/                  # Build output (gitignore)
├── app.vue                 # Główny komponent aplikacji
├── nuxt.config.ts          # Konfiguracja Nuxt
├── tsconfig.json           # TypeScript config (auto-generated)
├── assets/                 # Zasoby (style, obrazy)
│   └── css/
│       └── main.css
├── components/             # Vue komponenty (auto-import)
│   ├── AppHeader.vue
│   ├── AppFooter.vue
│   └── ui/
│       ├── Button.vue
│       └── Card.vue
├── composables/            # Vue composables (auto-import)
│   ├── useAuth.ts
│   └── useFetch.ts
├── layouts/                # Layouty stron
│   ├── default.vue
│   └── dashboard.vue
├── middleware/             # Route middleware
│   └── auth.ts
├── pages/                  # File-based routing
│   ├── index.vue           # /
│   ├── about.vue           # /about
│   └── blog/
│       ├── index.vue       # /blog
│       └── [slug].vue      # /blog/:slug
├── plugins/                # Vue plugins
│   └── api.ts
├── public/                 # Static files
│   └── favicon.ico
├── server/                 # Server routes (Nitro)
│   ├── api/
│   │   ├── users.get.ts
│   │   └── users.post.ts
│   └── middleware/
│       └── log.ts
└── utils/                  # Utility functions (auto-import)
    └── formatters.ts

Podstawowa konfiguracja nuxt.config.ts

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  // Włączenie DevTools
  devtools: { enabled: true },

  // Moduły Nuxt
  modules: [
    '@nuxt/ui',
    '@nuxt/image',
    '@pinia/nuxt',
    '@nuxtjs/tailwindcss',
    '@vueuse/nuxt',
  ],

  // Konfiguracja runtime
  runtimeConfig: {
    // Dostępne tylko na serwerze
    apiSecret: '',
    databaseUrl: '',
    // Dostępne też na kliencie
    public: {
      apiBase: '/api',
      appName: 'My App',
    },
  },

  // App config
  app: {
    head: {
      title: 'My Nuxt App',
      meta: [
        { name: 'description', content: 'Moja aplikacja Nuxt' },
        { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      ],
      link: [
        { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
      ],
    },
  },

  // TypeScript
  typescript: {
    strict: true,
    shim: false,
  },

  // Nitro server configuration
  nitro: {
    preset: 'vercel', // lub 'cloudflare', 'netlify', 'node-server'
  },

  // Experimental features
  experimental: {
    viewTransition: true,
  },
})

File-based Routing

Podstawowy routing

Code
TEXT
pages/
├── index.vue              # → /
├── about.vue              # → /about
├── contact.vue            # → /contact
├── blog/
│   ├── index.vue          # → /blog
│   └── [slug].vue         # → /blog/:slug (dynamic)
├── users/
│   ├── index.vue          # → /users
│   ├── [id].vue           # → /users/:id
│   └── [id]/
│       ├── edit.vue       # → /users/:id/edit
│       └── posts.vue      # → /users/:id/posts
├── [[...slug]].vue        # → catch-all route (optional)
└── products/
    └── [...slug].vue      # → /products/* (catch-all required)

Dynamic routes

pages/blog/[slug].vue
VUE
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
// Params są automatycznie typowane
const route = useRoute()
const slug = route.params.slug

// Fetch post data
const { data: post, pending, error } = await useFetch(`/api/posts/${slug}`)

// SEO
useHead({
  title: () => post.value?.title || 'Loading...',
  meta: [
    { name: 'description', content: () => post.value?.excerpt || '' },
  ],
})

// Obsługa 404
if (!post.value && !pending.value) {
  throw createError({
    statusCode: 404,
    statusMessage: 'Post not found',
  })
}
</script>

<template>
  <article v-if="post" class="max-w-3xl mx-auto py-8">
    <h1 class="text-4xl font-bold mb-4">{{ post.title }}</h1>
    <div class="text-gray-600 mb-8">
      {{ new Date(post.createdAt).toLocaleDateString('pl-PL') }}
    </div>
    <div class="prose" v-html="post.content" />
  </article>

  <div v-else-if="pending" class="flex justify-center py-20">
    <span class="loading loading-spinner loading-lg"></span>
  </div>

  <div v-else-if="error" class="text-center py-20 text-red-500">
    {{ error.message }}
  </div>
</template>

Nested routes

Code
VUE
<!-- pages/dashboard.vue - Parent route -->
<script setup lang="ts">
definePageMeta({
  layout: 'dashboard',
  middleware: 'auth',
})
</script>

<template>
  <div class="dashboard-container">
    <aside class="sidebar">
      <nav>
        <NuxtLink to="/dashboard">Overview</NuxtLink>
        <NuxtLink to="/dashboard/analytics">Analytics</NuxtLink>
        <NuxtLink to="/dashboard/settings">Settings</NuxtLink>
      </nav>
    </aside>

    <main class="content">
      <!-- Nested pages render here -->
      <NuxtPage />
    </main>
  </div>
</template>
pages/dashboard/analytics.vue
VUE
<!-- pages/dashboard/analytics.vue -->
<template>
  <div>
    <h2>Analytics Dashboard</h2>
    <AnalyticsChart />
  </div>
</template>

Data Fetching

useFetch - SSR-friendly fetch

Code
VUE
<script setup lang="ts">
// Podstawowe użycie
const { data: users, pending, error, refresh } = await useFetch('/api/users')

// Z opcjami
const { data: posts } = await useFetch('/api/posts', {
  method: 'GET',
  query: {
    page: 1,
    limit: 10,
  },
  headers: {
    'Accept-Language': 'pl',
  },
  // Transformacja danych
  transform: (data) => data.items,
  // Cache key
  key: 'posts-page-1',
  // Lazy loading (nie blokuje SSR)
  lazy: true,
  // Tylko na kliencie
  server: false,
  // Immediate fetch
  immediate: true,
  // Watch for changes
  watch: [page],
})

// POST request
const { data: newUser } = await useFetch('/api/users', {
  method: 'POST',
  body: {
    name: 'John',
    email: 'john@example.com',
  },
})
</script>

useAsyncData - Custom async logic

Code
VUE
<script setup lang="ts">
import type { User } from '~/types'

// Custom async logic
const { data: user, refresh } = await useAsyncData(
  'current-user',
  async () => {
    const token = useCookie('token')
    if (!token.value) return null

    const response = await $fetch<User>('/api/me', {
      headers: {
        Authorization: `Bearer ${token.value}`,
      },
    })
    return response
  },
  {
    // Re-fetch gdy token się zmieni
    watch: [useCookie('token')],
    // Default value
    default: () => null,
  }
)

// Parallel fetching
const [{ data: posts }, { data: categories }] = await Promise.all([
  useFetch('/api/posts'),
  useFetch('/api/categories'),
])
</script>

$fetch - Direct fetch

Code
VUE
<script setup lang="ts">
const form = reactive({
  email: '',
  password: '',
})

const isLoading = ref(false)
const error = ref<string | null>(null)

async function handleLogin() {
  isLoading.value = true
  error.value = null

  try {
    const response = await $fetch('/api/auth/login', {
      method: 'POST',
      body: form,
    })

    // Zapisz token
    const token = useCookie('token', { maxAge: 60 * 60 * 24 * 7 })
    token.value = response.token

    // Redirect
    await navigateTo('/dashboard')
  } catch (err: any) {
    error.value = err.data?.message || 'Login failed'
  } finally {
    isLoading.value = false
  }
}
</script>

Server Routes (Nitro API)

Podstawowe endpointy

TSserver/api/users.get.ts
TypeScript
// server/api/users.get.ts
export default defineEventHandler(async (event) => {
  // Query params
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const limit = Number(query.limit) || 10

  // Fetch from database (przykład z Prisma)
  const users = await prisma.user.findMany({
    skip: (page - 1) * limit,
    take: limit,
    select: {
      id: true,
      name: true,
      email: true,
      createdAt: true,
    },
  })

  const total = await prisma.user.count()

  return {
    users,
    pagination: {
      page,
      limit,
      total,
      pages: Math.ceil(total / limit),
    },
  }
})
TSserver/api/users.post.ts
TypeScript
// server/api/users.post.ts
import { z } from 'zod'

const userSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  password: z.string().min(8),
})

export default defineEventHandler(async (event) => {
  // Parse and validate body
  const body = await readBody(event)
  const result = userSchema.safeParse(body)

  if (!result.success) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Validation Error',
      data: result.error.issues,
    })
  }

  // Check if user exists
  const existing = await prisma.user.findUnique({
    where: { email: result.data.email },
  })

  if (existing) {
    throw createError({
      statusCode: 409,
      statusMessage: 'User already exists',
    })
  }

  // Hash password
  const hashedPassword = await hashPassword(result.data.password)

  // Create user
  const user = await prisma.user.create({
    data: {
      name: result.data.name,
      email: result.data.email,
      password: hashedPassword,
    },
    select: {
      id: true,
      name: true,
      email: true,
    },
  })

  setResponseStatus(event, 201)
  return user
})
TSserver/api/users/[id].ts
TypeScript
// server/api/users/[id].ts - Dynamic route
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  if (!id) {
    throw createError({
      statusCode: 400,
      statusMessage: 'User ID is required',
    })
  }

  const method = getMethod(event)

  switch (method) {
    case 'GET':
      return getUserById(id)
    case 'PUT':
      const body = await readBody(event)
      return updateUser(id, body)
    case 'DELETE':
      await deleteUser(id)
      return { success: true }
    default:
      throw createError({
        statusCode: 405,
        statusMessage: 'Method not allowed',
      })
  }
})

Server middleware

TSserver/middleware/auth.ts
TypeScript
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  // Pomijaj niektóre ścieżki
  const publicPaths = ['/api/auth/login', '/api/auth/register', '/api/health']
  if (publicPaths.includes(event.path)) return

  // Sprawdź Authorization header
  const authHeader = getRequestHeader(event, 'authorization')
  if (!authHeader?.startsWith('Bearer ')) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized',
    })
  }

  const token = authHeader.slice(7)

  try {
    const payload = await verifyToken(token)
    // Dodaj user do event context
    event.context.user = payload
  } catch {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid token',
    })
  }
})
TSserver/middleware/log.ts
TypeScript
// server/middleware/log.ts
export default defineEventHandler((event) => {
  console.log(`[${new Date().toISOString()}] ${getMethod(event)} ${event.path}`)
})

Auto-imports i Composables

Wbudowane auto-imports

Code
VUE
<script setup lang="ts">
// Wszystkie poniższe są automatycznie importowane!

// Vue Reactivity
const count = ref(0)
const doubled = computed(() => count.value * 2)
const user = reactive({ name: 'John', age: 25 })

// Vue Lifecycle
onMounted(() => console.log('Mounted'))
onUnmounted(() => console.log('Unmounted'))

// Nuxt Composables
const route = useRoute()           // Vue Router
const router = useRouter()         // Navigation
const { data } = await useFetch()  // Data fetching
const config = useRuntimeConfig()  // Runtime config
const cookie = useCookie('token')  // Cookie handling
const state = useState('key')      // Shared state
const head = useHead({})           // SEO
const seo = useSeoMeta({})         // SEO meta

// Nuxt Utilities
await navigateTo('/dashboard')     // Navigation
throw createError({})              // Error handling
await refreshNuxtData()            // Refresh data
</script>

Tworzenie własnych composables

TScomposables/useAuth.ts
TypeScript
// composables/useAuth.ts
interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
}

interface AuthState {
  user: User | null
  isAuthenticated: boolean
  isLoading: boolean
}

export function useAuth() {
  const user = useState<User | null>('auth:user', () => null)
  const isLoading = useState<boolean>('auth:loading', () => true)
  const token = useCookie('auth-token', {
    maxAge: 60 * 60 * 24 * 7, // 7 days
    secure: true,
    sameSite: 'strict',
  })

  const isAuthenticated = computed(() => !!user.value)
  const isAdmin = computed(() => user.value?.role === 'admin')

  async function login(email: string, password: string) {
    isLoading.value = true

    try {
      const response = await $fetch<{ user: User; token: string }>('/api/auth/login', {
        method: 'POST',
        body: { email, password },
      })

      user.value = response.user
      token.value = response.token

      await navigateTo('/dashboard')
    } finally {
      isLoading.value = false
    }
  }

  async function logout() {
    token.value = null
    user.value = null
    await navigateTo('/login')
  }

  async function fetchUser() {
    if (!token.value) {
      isLoading.value = false
      return
    }

    try {
      user.value = await $fetch<User>('/api/auth/me', {
        headers: { Authorization: `Bearer ${token.value}` },
      })
    } catch {
      token.value = null
    } finally {
      isLoading.value = false
    }
  }

  return {
    user: readonly(user),
    isAuthenticated,
    isAdmin,
    isLoading: readonly(isLoading),
    login,
    logout,
    fetchUser,
  }
}
TScomposables/useApi.ts
TypeScript
// composables/useApi.ts
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'

interface ApiOptions<T> {
  method?: HttpMethod
  body?: Record<string, any>
  query?: Record<string, any>
  transform?: (data: any) => T
}

export function useApi() {
  const config = useRuntimeConfig()
  const token = useCookie('auth-token')

  async function api<T>(
    endpoint: string,
    options: ApiOptions<T> = {}
  ): Promise<T> {
    const { method = 'GET', body, query, transform } = options

    const response = await $fetch<T>(endpoint, {
      baseURL: config.public.apiBase,
      method,
      body,
      query,
      headers: token.value
        ? { Authorization: `Bearer ${token.value}` }
        : {},
    })

    return transform ? transform(response) : response
  }

  return { api }
}
TScomposables/useToast.ts
TypeScript
// composables/useToast.ts
interface Toast {
  id: string
  message: string
  type: 'success' | 'error' | 'warning' | 'info'
  duration: number
}

export function useToast() {
  const toasts = useState<Toast[]>('toasts', () => [])

  function show(
    message: string,
    type: Toast['type'] = 'info',
    duration: number = 3000
  ) {
    const id = crypto.randomUUID()

    toasts.value.push({ id, message, type, duration })

    setTimeout(() => {
      remove(id)
    }, duration)
  }

  function remove(id: string) {
    toasts.value = toasts.value.filter((t) => t.id !== id)
  }

  return {
    toasts: readonly(toasts),
    show,
    success: (msg: string) => show(msg, 'success'),
    error: (msg: string) => show(msg, 'error'),
    warning: (msg: string) => show(msg, 'warning'),
    info: (msg: string) => show(msg, 'info'),
    remove,
  }
}

Komponenty

Wbudowane komponenty

Code
VUE
<template>
  <!-- NuxtLink - Client-side navigation -->
  <NuxtLink to="/about">About</NuxtLink>
  <NuxtLink :to="{ name: 'blog-slug', params: { slug: 'hello' } }">
    Blog Post
  </NuxtLink>
  <NuxtLink to="/external" external>External Link</NuxtLink>

  <!-- NuxtPage - Renders current page -->
  <NuxtPage />
  <NuxtPage :page-key="route.fullPath" />

  <!-- NuxtLayout - Layout wrapper -->
  <NuxtLayout name="dashboard">
    <slot />
  </NuxtLayout>

  <!-- NuxtLoadingIndicator - Route loading -->
  <NuxtLoadingIndicator color="#00DC82" />

  <!-- ClientOnly - Render only on client -->
  <ClientOnly>
    <ThreeJSCanvas />
    <template #fallback>
      <div>Loading 3D...</div>
    </template>
  </ClientOnly>

  <!-- NuxtImg - Optimized images (wymaga @nuxt/image) -->
  <NuxtImg
    src="/images/hero.jpg"
    width="800"
    height="600"
    format="webp"
    quality="80"
    loading="lazy"
  />

  <!-- NuxtPicture - Responsive images -->
  <NuxtPicture
    src="/images/hero.jpg"
    :width="800"
    :height="600"
    sizes="sm:100vw md:50vw lg:400px"
  />
</template>

Własne komponenty z auto-import

components/ui/Button.vue
VUE
<!-- components/ui/Button.vue -->
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  loading: false,
  disabled: false,
})

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

const classes = computed(() => {
  const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'

  const variants = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
    secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
    danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
    ghost: 'bg-transparent hover:bg-gray-100 focus:ring-gray-500',
  }

  const sizes = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-base',
    lg: 'px-6 py-3 text-lg',
  }

  return [
    base,
    variants[props.variant],
    sizes[props.size],
    (props.disabled || props.loading) && 'opacity-50 cursor-not-allowed',
  ]
})
</script>

<template>
  <button
    :class="classes"
    :disabled="disabled || loading"
    @click="emit('click', $event)"
  >
    <span v-if="loading" class="mr-2">
      <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
        <circle
          class="opacity-25"
          cx="12"
          cy="12"
          r="10"
          stroke="currentColor"
          stroke-width="4"
        />
        <path
          class="opacity-75"
          fill="currentColor"
          d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
        />
      </svg>
    </span>
    <slot />
  </button>
</template>
components/AppHeader.vue
VUE
<!-- components/AppHeader.vue -->
<script setup lang="ts">
const { isAuthenticated, user, logout } = useAuth()
const route = useRoute()

const navLinks = [
  { to: '/', label: 'Home' },
  { to: '/blog', label: 'Blog' },
  { to: '/about', label: 'About' },
]
</script>

<template>
  <header class="bg-white shadow-sm">
    <div class="container mx-auto px-4">
      <nav class="flex items-center justify-between h-16">
        <NuxtLink to="/" class="text-xl font-bold text-gray-900">
          MyApp
        </NuxtLink>

        <div class="flex items-center gap-6">
          <NuxtLink
            v-for="link in navLinks"
            :key="link.to"
            :to="link.to"
            class="text-gray-600 hover:text-gray-900"
            :class="{ 'text-blue-600 font-medium': route.path === link.to }"
          >
            {{ link.label }}
          </NuxtLink>

          <template v-if="isAuthenticated">
            <span class="text-gray-600">{{ user?.name }}</span>
            <UiButton variant="ghost" size="sm" @click="logout">
              Logout
            </UiButton>
          </template>

          <template v-else>
            <NuxtLink to="/login">
              <UiButton variant="primary" size="sm">Login</UiButton>
            </NuxtLink>
          </template>
        </div>
      </nav>
    </div>
  </header>
</template>

Layouts

layouts/default.vue
VUE
<!-- layouts/default.vue -->
<script setup lang="ts">
const { fetchUser, isLoading } = useAuth()

// Fetch user on app load
onMounted(() => {
  fetchUser()
})
</script>

<template>
  <div class="min-h-screen flex flex-col">
    <AppHeader />

    <main class="flex-1">
      <slot />
    </main>

    <AppFooter />

    <!-- Toast notifications -->
    <ToastContainer />

    <!-- Loading overlay -->
    <div
      v-if="isLoading"
      class="fixed inset-0 bg-white/80 flex items-center justify-center z-50"
    >
      <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
    </div>
  </div>
</template>
layouts/dashboard.vue
VUE
<!-- layouts/dashboard.vue -->
<script setup lang="ts">
const { user, isAdmin } = useAuth()
const route = useRoute()

const sidebarLinks = computed(() => {
  const links = [
    { to: '/dashboard', label: 'Overview', icon: 'home' },
    { to: '/dashboard/projects', label: 'Projects', icon: 'folder' },
    { to: '/dashboard/tasks', label: 'Tasks', icon: 'check-square' },
  ]

  if (isAdmin.value) {
    links.push(
      { to: '/dashboard/users', label: 'Users', icon: 'users' },
      { to: '/dashboard/settings', label: 'Settings', icon: 'settings' }
    )
  }

  return links
})
</script>

<template>
  <div class="min-h-screen flex">
    <!-- Sidebar -->
    <aside class="w-64 bg-gray-900 text-white">
      <div class="p-4">
        <NuxtLink to="/" class="text-xl font-bold">Dashboard</NuxtLink>
      </div>

      <nav class="mt-8">
        <NuxtLink
          v-for="link in sidebarLinks"
          :key="link.to"
          :to="link.to"
          class="flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-gray-800 hover:text-white"
          :class="{ 'bg-gray-800 text-white': route.path === link.to }"
        >
          <Icon :name="link.icon" class="w-5 h-5" />
          {{ link.label }}
        </NuxtLink>
      </nav>
    </aside>

    <!-- Main content -->
    <div class="flex-1 flex flex-col">
      <header class="bg-white shadow-sm h-16 flex items-center px-6">
        <span class="text-gray-600">Welcome, {{ user?.name }}</span>
      </header>

      <main class="flex-1 p-6 bg-gray-50">
        <slot />
      </main>
    </div>
  </div>
</template>

Middleware

Route middleware

TSmiddleware/auth.ts
TypeScript
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated } = useAuth()

  // Redirect to login if not authenticated
  if (!isAuthenticated.value) {
    return navigateTo({
      path: '/login',
      query: { redirect: to.fullPath },
    })
  }
})
TSmiddleware/admin.ts
TypeScript
// middleware/admin.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { user, isAdmin } = useAuth()

  if (!isAdmin.value) {
    throw createError({
      statusCode: 403,
      statusMessage: 'Access denied. Admin only.',
    })
  }
})
TSmiddleware/guest.ts
TypeScript
// middleware/guest.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated } = useAuth()

  // Redirect to dashboard if already logged in
  if (isAuthenticated.value) {
    return navigateTo('/dashboard')
  }
})

Użycie middleware

Code
VUE
<script setup lang="ts">
// Pojedynczy middleware
definePageMeta({
  middleware: 'auth',
})

// Wiele middleware
definePageMeta({
  middleware: ['auth', 'admin'],
})

// Inline middleware
definePageMeta({
  middleware: [
    function (to, from) {
      console.log('Navigating to:', to.path)
    },
  ],
})
</script>

Global middleware

TSmiddleware/analytics.global.ts
TypeScript
// middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
  // Track page views
  if (process.client) {
    trackPageView(to.fullPath)
  }
})

Rendering Modes

Konfiguracja rendering w route rules

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // SSR - domyślne
    '/': { ssr: true },

    // SSG - pre-rendered at build time
    '/about': { prerender: true },
    '/blog/**': { prerender: true },

    // SPA - client-side only
    '/dashboard/**': { ssr: false },
    '/admin/**': { ssr: false },

    // ISR - Incremental Static Regeneration
    '/products/**': { swr: 3600 }, // Revalidate every hour
    '/api/products/**': { swr: 60 }, // API cache 1 minute

    // Static with revalidation
    '/pricing': {
      prerender: true,
      headers: { 'cache-control': 's-maxage=3600' },
    },

    // CDN cache
    '/static/**': {
      headers: { 'cache-control': 'public, max-age=31536000, immutable' },
    },

    // Redirect
    '/old-page': { redirect: '/new-page' },

    // CORS for API
    '/api/**': {
      cors: true,
      headers: {
        'Access-Control-Allow-Origin': '*',
      },
    },
  },
})

Integracja z Pinia

Instalacja i konfiguracja

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
})

Store definition

TSstores/user.ts
TypeScript
// stores/user.ts
import { defineStore } from 'pinia'

interface User {
  id: string
  name: string
  email: string
}

export const useUserStore = defineStore('user', () => {
  // State
  const user = ref<User | null>(null)
  const isLoading = ref(false)

  // Getters
  const isLoggedIn = computed(() => !!user.value)
  const displayName = computed(() => user.value?.name || 'Guest')

  // Actions
  async function fetchUser() {
    isLoading.value = true
    try {
      const data = await $fetch<User>('/api/me')
      user.value = data
    } catch {
      user.value = null
    } finally {
      isLoading.value = false
    }
  }

  function setUser(userData: User) {
    user.value = userData
  }

  function clearUser() {
    user.value = null
  }

  return {
    user,
    isLoading,
    isLoggedIn,
    displayName,
    fetchUser,
    setUser,
    clearUser,
  }
})

Użycie store w komponentach

Code
VUE
<script setup lang="ts">
const userStore = useUserStore()

// SSR-safe hydration
if (process.server) {
  await userStore.fetchUser()
}
</script>

<template>
  <div>
    <p v-if="userStore.isLoggedIn">
      Welcome, {{ userStore.displayName }}
    </p>
  </div>
</template>

Nuxt Modules - popularne rozszerzenia

@nuxt/ui - Oficjalna biblioteka komponentów

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui'],
  ui: {
    icons: ['heroicons', 'lucide'],
  },
})
Code
VUE
<template>
  <!-- Buttons -->
  <UButton color="primary" size="lg">Click me</UButton>
  <UButton icon="i-heroicons-plus" />

  <!-- Forms -->
  <UInput v-model="email" placeholder="Email" />
  <USelect v-model="country" :options="countries" />
  <UTextarea v-model="message" rows="4" />

  <!-- Feedback -->
  <UAlert title="Success!" color="green" />
  <UBadge color="blue">New</UBadge>

  <!-- Navigation -->
  <UDropdown :items="menuItems">
    <UButton>Menu</UButton>
  </UDropdown>

  <!-- Overlays -->
  <UModal v-model="isOpen">
    <UCard>
      <template #header>Modal Title</template>
      Content here
    </UCard>
  </UModal>
</template>

@nuxt/image - Optymalizacja obrazów

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/image'],
  image: {
    provider: 'vercel', // lub 'cloudinary', 'imgix', 'ipx'
    quality: 80,
    format: ['webp', 'avif'],
  },
})

@nuxtjs/i18n - Internationalization

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'en', name: 'English', file: 'en.json' },
      { code: 'pl', name: 'Polski', file: 'pl.json' },
    ],
    defaultLocale: 'pl',
    langDir: 'locales/',
  },
})

@vueuse/nuxt - Collection of Vue composition utilities

Code
VUE
<script setup lang="ts">
// Auto-imported z VueUse
const { x, y } = useMouse()
const { isFullscreen, toggle } = useFullscreen()
const isDark = useDark()
const { copy } = useClipboard()
const { width, height } = useWindowSize()
</script>

SEO i Meta Tags

Code
VUE
<script setup lang="ts">
// Podstawowe meta
useHead({
  title: 'Moja Strona',
  meta: [
    { name: 'description', content: 'Opis strony' },
    { name: 'keywords', content: 'nuxt, vue, typescript' },
  ],
  link: [
    { rel: 'canonical', href: 'https://example.com/page' },
  ],
  script: [
    { src: 'https://analytics.example.com/script.js', async: true },
  ],
})

// SEO-specific meta
useSeoMeta({
  title: 'Moja Strona',
  description: 'Opis strony dla SEO',
  ogTitle: 'Moja Strona - Open Graph',
  ogDescription: 'Opis dla social media',
  ogImage: 'https://example.com/og-image.jpg',
  ogType: 'website',
  twitterCard: 'summary_large_image',
  twitterTitle: 'Moja Strona',
  twitterDescription: 'Opis dla Twitter',
})

// Dynamic titles
const { data: post } = await useFetch('/api/post/1')
useHead({
  title: () => post.value?.title || 'Loading...',
})
</script>

Error Handling

Code
VUE
<!-- error.vue - Global error page -->
<script setup lang="ts">
interface Props {
  error: {
    statusCode: number
    statusMessage: string
    message: string
  }
}

const props = defineProps<Props>()

const handleError = () => clearError({ redirect: '/' })
</script>

<template>
  <div class="min-h-screen flex items-center justify-center">
    <div class="text-center">
      <h1 class="text-6xl font-bold text-gray-900">
        {{ error.statusCode }}
      </h1>
      <p class="mt-4 text-xl text-gray-600">
        {{ error.statusMessage || 'An error occurred' }}
      </p>
      <button
        @click="handleError"
        class="mt-8 px-6 py-3 bg-blue-600 text-white rounded-lg"
      >
        Go Home
      </button>
    </div>
  </div>
</template>

Deployment

Vercel

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    preset: 'vercel',
  },
})

Cloudflare Pages

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    preset: 'cloudflare-pages',
  },
})

Docker

Code
DOCKERFILE
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output .output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

Cennik

  • 100% darmowy - MIT License
  • NuxtHub: Hosting z bazą danych - pay as you go
    • Free tier: 100K requests/month
    • Pro: $20/month - unlimited requests
  • Nuxt UI Pro: Premium komponenty - $299 (lifetime)

FAQ - Często zadawane pytania

Czym różni się Nuxt od Vue CLI?

Vue CLI tworzy podstawowe aplikacje Vue (SPA), podczas gdy Nuxt to pełny framework oferujący SSR, SSG, file-based routing, auto-imports, server routes i wiele więcej out-of-the-box. Nuxt jest do Vue tym, czym Next.js jest do React.

Kiedy wybrać Nuxt zamiast Next.js?

Wybierz Nuxt jeśli preferujesz Vue.js, potrzebujesz auto-imports i ekosystemu modułów Nuxt. Next.js jest lepszy jeśli preferujesz React lub potrzebujesz większego ekosystemu bibliotek React.

Czy Nuxt nadaje się do dużych aplikacji enterprise?

Tak. Nuxt jest używany przez duże firmy jak GitLab, Adobe, Upwork. Oferuje TypeScript, moduły, middleware i pełną kontrolę nad renderingiem i cachingiem.

Jak migrować z Nuxt 2 do Nuxt 3?

Nuxt 3 to całkowita rewrite. Główne zmiany: Vue 3 Composition API, Nitro server, nowy system modułów. Anthropic oferuje narzędzie nuxi upgrade i szczegółowy migration guide w dokumentacji.

Czy mogę używać Nuxt bez SSR?

Tak. Możesz ustawić ssr: false w nuxt.config.ts dla całej aplikacji lub używać routeRules dla wybranych stron. To tworzy SPA podobne do tradycyjnej aplikacji Vue.


Nuxt - The Intuitive Vue Framework

What is Nuxt?

Nuxt is a powerful fullstack framework built on Vue.js, inspired by the architecture of Next.js. It offers a "batteries-included" approach to building modern web applications with Server-Side Rendering (SSR), Static Site Generation (SSG), file-based routing, and automatic imports. Nuxt 3, released in 2022, was completely rewritten using Vue 3 Composition API, TypeScript, and a new server engine called Nitro.

The framework is developed by the NuxtLabs team and has a very active community with over 200 official modules that extend its functionality. Nuxt is used by companies like GitLab, Upwork, Nintendo, and Trivago to build high-performance production applications.

Why Nuxt?

Key advantages of the framework

  1. Zero configuration - Works right out of the box with sensible default settings
  2. Auto-imports - Components, composables, and utilities are automatically imported
  3. Hybrid Rendering - SSR, SSG, ISR, SPA in a single application
  4. Nitro Engine - Universal server running on edge, serverless, and traditional hosting
  5. TypeScript first - Full TypeScript support without additional configuration
  6. Developer Experience - Hot Module Replacement, Vue DevTools, excellent error messages

Nuxt vs Next.js vs SvelteKit

FeatureNuxt 3Next.js 14SvelteKit
Base frameworkVue 3React 18Svelte 4
RenderingSSR/SSG/ISR/SPASSR/SSG/ISR/SPASSR/SSG/SPA
File routingYesYesYes
Auto-importsFullPartialPartial
Server engineNitroEdge RuntimeAdapters
TypeScriptNativeNativeNative
Bundle sizeSmallMediumSmallest
Learning curveMediumMediumLow
Module ecosystem200+No official ones~50
Edge deploymentYesYesYes

Installation and configuration

Creating a new project

Code
Bash
# Inicjalizacja projektu
npx nuxi@latest init my-app

# Przejście do katalogu
cd my-app

# Instalacja zależności
npm install

# Uruchomienie dev server
npm run dev

Nuxt 3 project structure

Code
TEXT
my-nuxt-app/
├── .nuxt/                  # Build output (gitignore)
├── app.vue                 # Główny komponent aplikacji
├── nuxt.config.ts          # Konfiguracja Nuxt
├── tsconfig.json           # TypeScript config (auto-generated)
├── assets/                 # Zasoby (style, obrazy)
│   └── css/
│       └── main.css
├── components/             # Vue komponenty (auto-import)
│   ├── AppHeader.vue
│   ├── AppFooter.vue
│   └── ui/
│       ├── Button.vue
│       └── Card.vue
├── composables/            # Vue composables (auto-import)
│   ├── useAuth.ts
│   └── useFetch.ts
├── layouts/                # Layouty stron
│   ├── default.vue
│   └── dashboard.vue
├── middleware/             # Route middleware
│   └── auth.ts
├── pages/                  # File-based routing
│   ├── index.vue           # /
│   ├── about.vue           # /about
│   └── blog/
│       ├── index.vue       # /blog
│       └── [slug].vue      # /blog/:slug
├── plugins/                # Vue plugins
│   └── api.ts
├── public/                 # Static files
│   └── favicon.ico
├── server/                 # Server routes (Nitro)
│   ├── api/
│   │   ├── users.get.ts
│   │   └── users.post.ts
│   └── middleware/
│       └── log.ts
└── utils/                  # Utility functions (auto-import)
    └── formatters.ts

Basic nuxt.config.ts configuration

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  // Włączenie DevTools
  devtools: { enabled: true },

  // Moduły Nuxt
  modules: [
    '@nuxt/ui',
    '@nuxt/image',
    '@pinia/nuxt',
    '@nuxtjs/tailwindcss',
    '@vueuse/nuxt',
  ],

  // Konfiguracja runtime
  runtimeConfig: {
    // Dostępne tylko na serwerze
    apiSecret: '',
    databaseUrl: '',
    // Dostępne też na kliencie
    public: {
      apiBase: '/api',
      appName: 'My App',
    },
  },

  // App config
  app: {
    head: {
      title: 'My Nuxt App',
      meta: [
        { name: 'description', content: 'Moja aplikacja Nuxt' },
        { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      ],
      link: [
        { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
      ],
    },
  },

  // TypeScript
  typescript: {
    strict: true,
    shim: false,
  },

  // Nitro server configuration
  nitro: {
    preset: 'vercel', // lub 'cloudflare', 'netlify', 'node-server'
  },

  // Experimental features
  experimental: {
    viewTransition: true,
  },
})

File-based Routing

Basic routing

Code
TEXT
pages/
├── index.vue              # → /
├── about.vue              # → /about
├── contact.vue            # → /contact
├── blog/
│   ├── index.vue          # → /blog
│   └── [slug].vue         # → /blog/:slug (dynamic)
├── users/
│   ├── index.vue          # → /users
│   ├── [id].vue           # → /users/:id
│   └── [id]/
│       ├── edit.vue       # → /users/:id/edit
│       └── posts.vue      # → /users/:id/posts
├── [[...slug]].vue        # → catch-all route (optional)
└── products/
    └── [...slug].vue      # → /products/* (catch-all required)

Dynamic routes

pages/blog/[slug].vue
VUE
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
// Params są automatycznie typowane
const route = useRoute()
const slug = route.params.slug

// Fetch post data
const { data: post, pending, error } = await useFetch(`/api/posts/${slug}`)

// SEO
useHead({
  title: () => post.value?.title || 'Loading...',
  meta: [
    { name: 'description', content: () => post.value?.excerpt || '' },
  ],
})

// Obsługa 404
if (!post.value && !pending.value) {
  throw createError({
    statusCode: 404,
    statusMessage: 'Post not found',
  })
}
</script>

<template>
  <article v-if="post" class="max-w-3xl mx-auto py-8">
    <h1 class="text-4xl font-bold mb-4">{{ post.title }}</h1>
    <div class="text-gray-600 mb-8">
      {{ new Date(post.createdAt).toLocaleDateString('pl-PL') }}
    </div>
    <div class="prose" v-html="post.content" />
  </article>

  <div v-else-if="pending" class="flex justify-center py-20">
    <span class="loading loading-spinner loading-lg"></span>
  </div>

  <div v-else-if="error" class="text-center py-20 text-red-500">
    {{ error.message }}
  </div>
</template>

Nested routes

Code
VUE
<!-- pages/dashboard.vue - Parent route -->
<script setup lang="ts">
definePageMeta({
  layout: 'dashboard',
  middleware: 'auth',
})
</script>

<template>
  <div class="dashboard-container">
    <aside class="sidebar">
      <nav>
        <NuxtLink to="/dashboard">Overview</NuxtLink>
        <NuxtLink to="/dashboard/analytics">Analytics</NuxtLink>
        <NuxtLink to="/dashboard/settings">Settings</NuxtLink>
      </nav>
    </aside>

    <main class="content">
      <!-- Nested pages render here -->
      <NuxtPage />
    </main>
  </div>
</template>
pages/dashboard/analytics.vue
VUE
<!-- pages/dashboard/analytics.vue -->
<template>
  <div>
    <h2>Analytics Dashboard</h2>
    <AnalyticsChart />
  </div>
</template>

Data Fetching

useFetch - SSR-friendly fetch

Code
VUE
<script setup lang="ts">
// Podstawowe użycie
const { data: users, pending, error, refresh } = await useFetch('/api/users')

// Z opcjami
const { data: posts } = await useFetch('/api/posts', {
  method: 'GET',
  query: {
    page: 1,
    limit: 10,
  },
  headers: {
    'Accept-Language': 'pl',
  },
  // Transformacja danych
  transform: (data) => data.items,
  // Cache key
  key: 'posts-page-1',
  // Lazy loading (nie blokuje SSR)
  lazy: true,
  // Tylko na kliencie
  server: false,
  // Immediate fetch
  immediate: true,
  // Watch for changes
  watch: [page],
})

// POST request
const { data: newUser } = await useFetch('/api/users', {
  method: 'POST',
  body: {
    name: 'John',
    email: 'john@example.com',
  },
})
</script>

useAsyncData - Custom async logic

Code
VUE
<script setup lang="ts">
import type { User } from '~/types'

// Custom async logic
const { data: user, refresh } = await useAsyncData(
  'current-user',
  async () => {
    const token = useCookie('token')
    if (!token.value) return null

    const response = await $fetch<User>('/api/me', {
      headers: {
        Authorization: `Bearer ${token.value}`,
      },
    })
    return response
  },
  {
    // Re-fetch gdy token się zmieni
    watch: [useCookie('token')],
    // Default value
    default: () => null,
  }
)

// Parallel fetching
const [{ data: posts }, { data: categories }] = await Promise.all([
  useFetch('/api/posts'),
  useFetch('/api/categories'),
])
</script>

$fetch - Direct fetch

Code
VUE
<script setup lang="ts">
const form = reactive({
  email: '',
  password: '',
})

const isLoading = ref(false)
const error = ref<string | null>(null)

async function handleLogin() {
  isLoading.value = true
  error.value = null

  try {
    const response = await $fetch('/api/auth/login', {
      method: 'POST',
      body: form,
    })

    // Zapisz token
    const token = useCookie('token', { maxAge: 60 * 60 * 24 * 7 })
    token.value = response.token

    // Redirect
    await navigateTo('/dashboard')
  } catch (err: any) {
    error.value = err.data?.message || 'Login failed'
  } finally {
    isLoading.value = false
  }
}
</script>

Server Routes (Nitro API)

Basic endpoints

TSserver/api/users.get.ts
TypeScript
// server/api/users.get.ts
export default defineEventHandler(async (event) => {
  // Query params
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const limit = Number(query.limit) || 10

  // Fetch from database (przykład z Prisma)
  const users = await prisma.user.findMany({
    skip: (page - 1) * limit,
    take: limit,
    select: {
      id: true,
      name: true,
      email: true,
      createdAt: true,
    },
  })

  const total = await prisma.user.count()

  return {
    users,
    pagination: {
      page,
      limit,
      total,
      pages: Math.ceil(total / limit),
    },
  }
})
TSserver/api/users.post.ts
TypeScript
// server/api/users.post.ts
import { z } from 'zod'

const userSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  password: z.string().min(8),
})

export default defineEventHandler(async (event) => {
  // Parse and validate body
  const body = await readBody(event)
  const result = userSchema.safeParse(body)

  if (!result.success) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Validation Error',
      data: result.error.issues,
    })
  }

  // Check if user exists
  const existing = await prisma.user.findUnique({
    where: { email: result.data.email },
  })

  if (existing) {
    throw createError({
      statusCode: 409,
      statusMessage: 'User already exists',
    })
  }

  // Hash password
  const hashedPassword = await hashPassword(result.data.password)

  // Create user
  const user = await prisma.user.create({
    data: {
      name: result.data.name,
      email: result.data.email,
      password: hashedPassword,
    },
    select: {
      id: true,
      name: true,
      email: true,
    },
  })

  setResponseStatus(event, 201)
  return user
})
TSserver/api/users/[id].ts
TypeScript
// server/api/users/[id].ts - Dynamic route
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  if (!id) {
    throw createError({
      statusCode: 400,
      statusMessage: 'User ID is required',
    })
  }

  const method = getMethod(event)

  switch (method) {
    case 'GET':
      return getUserById(id)
    case 'PUT':
      const body = await readBody(event)
      return updateUser(id, body)
    case 'DELETE':
      await deleteUser(id)
      return { success: true }
    default:
      throw createError({
        statusCode: 405,
        statusMessage: 'Method not allowed',
      })
  }
})

Server middleware

TSserver/middleware/auth.ts
TypeScript
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  // Pomijaj niektóre ścieżki
  const publicPaths = ['/api/auth/login', '/api/auth/register', '/api/health']
  if (publicPaths.includes(event.path)) return

  // Sprawdź Authorization header
  const authHeader = getRequestHeader(event, 'authorization')
  if (!authHeader?.startsWith('Bearer ')) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized',
    })
  }

  const token = authHeader.slice(7)

  try {
    const payload = await verifyToken(token)
    // Dodaj user do event context
    event.context.user = payload
  } catch {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid token',
    })
  }
})
TSserver/middleware/log.ts
TypeScript
// server/middleware/log.ts
export default defineEventHandler((event) => {
  console.log(`[${new Date().toISOString()}] ${getMethod(event)} ${event.path}`)
})

Auto-imports and Composables

Built-in auto-imports

Code
VUE
<script setup lang="ts">
// Wszystkie poniższe są automatycznie importowane!

// Vue Reactivity
const count = ref(0)
const doubled = computed(() => count.value * 2)
const user = reactive({ name: 'John', age: 25 })

// Vue Lifecycle
onMounted(() => console.log('Mounted'))
onUnmounted(() => console.log('Unmounted'))

// Nuxt Composables
const route = useRoute()           // Vue Router
const router = useRouter()         // Navigation
const { data } = await useFetch()  // Data fetching
const config = useRuntimeConfig()  // Runtime config
const cookie = useCookie('token')  // Cookie handling
const state = useState('key')      // Shared state
const head = useHead({})           // SEO
const seo = useSeoMeta({})         // SEO meta

// Nuxt Utilities
await navigateTo('/dashboard')     // Navigation
throw createError({})              // Error handling
await refreshNuxtData()            // Refresh data
</script>

Nuxt automatically imports all Vue reactivity primitives (ref, computed, reactive, watch, watchEffect), lifecycle hooks (onMounted, onUnmounted, onBeforeMount), all built-in Nuxt composables (useFetch, useAsyncData, useRoute, useRouter, useHead, useSeoMeta, useCookie, useState, useRuntimeConfig), and utility functions (navigateTo, createError, definePageMeta, refreshNuxtData). You never need to write manual import statements for any of these -- they just work everywhere in your .vue files, composables, and utilities.

Creating custom composables

TScomposables/useAuth.ts
TypeScript
// composables/useAuth.ts
interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
}

interface AuthState {
  user: User | null
  isAuthenticated: boolean
  isLoading: boolean
}

export function useAuth() {
  const user = useState<User | null>('auth:user', () => null)
  const isLoading = useState<boolean>('auth:loading', () => true)
  const token = useCookie('auth-token', {
    maxAge: 60 * 60 * 24 * 7, // 7 days
    secure: true,
    sameSite: 'strict',
  })

  const isAuthenticated = computed(() => !!user.value)
  const isAdmin = computed(() => user.value?.role === 'admin')

  async function login(email: string, password: string) {
    isLoading.value = true

    try {
      const response = await $fetch<{ user: User; token: string }>('/api/auth/login', {
        method: 'POST',
        body: { email, password },
      })

      user.value = response.user
      token.value = response.token

      await navigateTo('/dashboard')
    } finally {
      isLoading.value = false
    }
  }

  async function logout() {
    token.value = null
    user.value = null
    await navigateTo('/login')
  }

  async function fetchUser() {
    if (!token.value) {
      isLoading.value = false
      return
    }

    try {
      user.value = await $fetch<User>('/api/auth/me', {
        headers: { Authorization: `Bearer ${token.value}` },
      })
    } catch {
      token.value = null
    } finally {
      isLoading.value = false
    }
  }

  return {
    user: readonly(user),
    isAuthenticated,
    isAdmin,
    isLoading: readonly(isLoading),
    login,
    logout,
    fetchUser,
  }
}

Any file you place inside the composables/ directory is automatically available throughout your entire application without imports. The convention is to name the exported function with a use prefix (like useAuth, useApi, useToast). This pattern keeps your code clean and promotes reusability across components. You can also create subdirectories inside composables/ -- Nuxt will scan them recursively.

TScomposables/useApi.ts
TypeScript
// composables/useApi.ts
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'

interface ApiOptions<T> {
  method?: HttpMethod
  body?: Record<string, any>
  query?: Record<string, any>
  transform?: (data: any) => T
}

export function useApi() {
  const config = useRuntimeConfig()
  const token = useCookie('auth-token')

  async function api<T>(
    endpoint: string,
    options: ApiOptions<T> = {}
  ): Promise<T> {
    const { method = 'GET', body, query, transform } = options

    const response = await $fetch<T>(endpoint, {
      baseURL: config.public.apiBase,
      method,
      body,
      query,
      headers: token.value
        ? { Authorization: `Bearer ${token.value}` }
        : {},
    })

    return transform ? transform(response) : response
  }

  return { api }
}
TScomposables/useToast.ts
TypeScript
// composables/useToast.ts
interface Toast {
  id: string
  message: string
  type: 'success' | 'error' | 'warning' | 'info'
  duration: number
}

export function useToast() {
  const toasts = useState<Toast[]>('toasts', () => [])

  function show(
    message: string,
    type: Toast['type'] = 'info',
    duration: number = 3000
  ) {
    const id = crypto.randomUUID()

    toasts.value.push({ id, message, type, duration })

    setTimeout(() => {
      remove(id)
    }, duration)
  }

  function remove(id: string) {
    toasts.value = toasts.value.filter((t) => t.id !== id)
  }

  return {
    toasts: readonly(toasts),
    show,
    success: (msg: string) => show(msg, 'success'),
    error: (msg: string) => show(msg, 'error'),
    warning: (msg: string) => show(msg, 'warning'),
    info: (msg: string) => show(msg, 'info'),
    remove,
  }
}

Components

Built-in components

Code
VUE
<template>
  <!-- NuxtLink - Client-side navigation -->
  <NuxtLink to="/about">About</NuxtLink>
  <NuxtLink :to="{ name: 'blog-slug', params: { slug: 'hello' } }">
    Blog Post
  </NuxtLink>
  <NuxtLink to="/external" external>External Link</NuxtLink>

  <!-- NuxtPage - Renders current page -->
  <NuxtPage />
  <NuxtPage :page-key="route.fullPath" />

  <!-- NuxtLayout - Layout wrapper -->
  <NuxtLayout name="dashboard">
    <slot />
  </NuxtLayout>

  <!-- NuxtLoadingIndicator - Route loading -->
  <NuxtLoadingIndicator color="#00DC82" />

  <!-- ClientOnly - Render only on client -->
  <ClientOnly>
    <ThreeJSCanvas />
    <template #fallback>
      <div>Loading 3D...</div>
    </template>
  </ClientOnly>

  <!-- NuxtImg - Optimized images (wymaga @nuxt/image) -->
  <NuxtImg
    src="/images/hero.jpg"
    width="800"
    height="600"
    format="webp"
    quality="80"
    loading="lazy"
  />

  <!-- NuxtPicture - Responsive images -->
  <NuxtPicture
    src="/images/hero.jpg"
    :width="800"
    :height="600"
    sizes="sm:100vw md:50vw lg:400px"
  />
</template>

Nuxt ships with several built-in components that handle common patterns. NuxtLink provides client-side navigation with automatic prefetching of linked pages. NuxtPage renders the current route's page component and is essential for nested routing. ClientOnly is especially useful when you have components that rely on browser APIs (like canvas, WebGL, or window measurements) -- it prevents them from running during SSR and optionally shows a fallback while loading. NuxtImg and NuxtPicture (from the @nuxt/image module) handle image optimization with automatic format conversion, lazy loading, and responsive sizing.

Custom components with auto-import

components/ui/Button.vue
VUE
<!-- components/ui/Button.vue -->
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  loading: false,
  disabled: false,
})

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

const classes = computed(() => {
  const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'

  const variants = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
    secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
    danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
    ghost: 'bg-transparent hover:bg-gray-100 focus:ring-gray-500',
  }

  const sizes = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-base',
    lg: 'px-6 py-3 text-lg',
  }

  return [
    base,
    variants[props.variant],
    sizes[props.size],
    (props.disabled || props.loading) && 'opacity-50 cursor-not-allowed',
  ]
})
</script>

<template>
  <button
    :class="classes"
    :disabled="disabled || loading"
    @click="emit('click', $event)"
  >
    <span v-if="loading" class="mr-2">
      <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
        <circle
          class="opacity-25"
          cx="12"
          cy="12"
          r="10"
          stroke="currentColor"
          stroke-width="4"
        />
        <path
          class="opacity-75"
          fill="currentColor"
          d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
        />
      </svg>
    </span>
    <slot />
  </button>
</template>

All Vue components placed in the components/ directory are automatically imported and available throughout your application. The naming convention follows the directory structure -- a component at components/ui/Button.vue becomes <UiButton /> in your templates. This eliminates the need for manual import statements and keeps your code concise.

components/AppHeader.vue
VUE
<!-- components/AppHeader.vue -->
<script setup lang="ts">
const { isAuthenticated, user, logout } = useAuth()
const route = useRoute()

const navLinks = [
  { to: '/', label: 'Home' },
  { to: '/blog', label: 'Blog' },
  { to: '/about', label: 'About' },
]
</script>

<template>
  <header class="bg-white shadow-sm">
    <div class="container mx-auto px-4">
      <nav class="flex items-center justify-between h-16">
        <NuxtLink to="/" class="text-xl font-bold text-gray-900">
          MyApp
        </NuxtLink>

        <div class="flex items-center gap-6">
          <NuxtLink
            v-for="link in navLinks"
            :key="link.to"
            :to="link.to"
            class="text-gray-600 hover:text-gray-900"
            :class="{ 'text-blue-600 font-medium': route.path === link.to }"
          >
            {{ link.label }}
          </NuxtLink>

          <template v-if="isAuthenticated">
            <span class="text-gray-600">{{ user?.name }}</span>
            <UiButton variant="ghost" size="sm" @click="logout">
              Logout
            </UiButton>
          </template>

          <template v-else>
            <NuxtLink to="/login">
              <UiButton variant="primary" size="sm">Login</UiButton>
            </NuxtLink>
          </template>
        </div>
      </nav>
    </div>
  </header>
</template>

Layouts

layouts/default.vue
VUE
<!-- layouts/default.vue -->
<script setup lang="ts">
const { fetchUser, isLoading } = useAuth()

// Fetch user on app load
onMounted(() => {
  fetchUser()
})
</script>

<template>
  <div class="min-h-screen flex flex-col">
    <AppHeader />

    <main class="flex-1">
      <slot />
    </main>

    <AppFooter />

    <!-- Toast notifications -->
    <ToastContainer />

    <!-- Loading overlay -->
    <div
      v-if="isLoading"
      class="fixed inset-0 bg-white/80 flex items-center justify-center z-50"
    >
      <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
    </div>
  </div>
</template>

Layouts in Nuxt wrap your page content with shared UI elements like headers, footers, and sidebars. The default.vue layout is applied automatically to all pages unless you specify otherwise. You can create multiple layouts and switch between them on a per-page basis using definePageMeta({ layout: 'dashboard' }). The <slot /> element inside the layout is where the page content gets rendered.

layouts/dashboard.vue
VUE
<!-- layouts/dashboard.vue -->
<script setup lang="ts">
const { user, isAdmin } = useAuth()
const route = useRoute()

const sidebarLinks = computed(() => {
  const links = [
    { to: '/dashboard', label: 'Overview', icon: 'home' },
    { to: '/dashboard/projects', label: 'Projects', icon: 'folder' },
    { to: '/dashboard/tasks', label: 'Tasks', icon: 'check-square' },
  ]

  if (isAdmin.value) {
    links.push(
      { to: '/dashboard/users', label: 'Users', icon: 'users' },
      { to: '/dashboard/settings', label: 'Settings', icon: 'settings' }
    )
  }

  return links
})
</script>

<template>
  <div class="min-h-screen flex">
    <!-- Sidebar -->
    <aside class="w-64 bg-gray-900 text-white">
      <div class="p-4">
        <NuxtLink to="/" class="text-xl font-bold">Dashboard</NuxtLink>
      </div>

      <nav class="mt-8">
        <NuxtLink
          v-for="link in sidebarLinks"
          :key="link.to"
          :to="link.to"
          class="flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-gray-800 hover:text-white"
          :class="{ 'bg-gray-800 text-white': route.path === link.to }"
        >
          <Icon :name="link.icon" class="w-5 h-5" />
          {{ link.label }}
        </NuxtLink>
      </nav>
    </aside>

    <!-- Main content -->
    <div class="flex-1 flex flex-col">
      <header class="bg-white shadow-sm h-16 flex items-center px-6">
        <span class="text-gray-600">Welcome, {{ user?.name }}</span>
      </header>

      <main class="flex-1 p-6 bg-gray-50">
        <slot />
      </main>
    </div>
  </div>
</template>

Middleware

Route middleware

TSmiddleware/auth.ts
TypeScript
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated } = useAuth()

  // Redirect to login if not authenticated
  if (!isAuthenticated.value) {
    return navigateTo({
      path: '/login',
      query: { redirect: to.fullPath },
    })
  }
})
TSmiddleware/admin.ts
TypeScript
// middleware/admin.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { user, isAdmin } = useAuth()

  if (!isAdmin.value) {
    throw createError({
      statusCode: 403,
      statusMessage: 'Access denied. Admin only.',
    })
  }
})
TSmiddleware/guest.ts
TypeScript
// middleware/guest.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated } = useAuth()

  // Redirect to dashboard if already logged in
  if (isAuthenticated.value) {
    return navigateTo('/dashboard')
  }
})

Route middleware in Nuxt runs before navigating to a particular route. It is perfect for authentication checks, role-based access control, and redirect logic. You define middleware as files in the middleware/ directory and then reference them by name in your page components.

Using middleware

Code
VUE
<script setup lang="ts">
// Pojedynczy middleware
definePageMeta({
  middleware: 'auth',
})

// Wiele middleware
definePageMeta({
  middleware: ['auth', 'admin'],
})

// Inline middleware
definePageMeta({
  middleware: [
    function (to, from) {
      console.log('Navigating to:', to.path)
    },
  ],
})
</script>

You can apply a single middleware by passing its name as a string, multiple middleware by passing an array of names, or even define inline middleware functions directly in definePageMeta. Middleware executes in the order it is listed, so if you have ['auth', 'admin'], the auth check runs first. If auth redirects or throws an error, the admin middleware never executes.

Global middleware

TSmiddleware/analytics.global.ts
TypeScript
// middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to, from) => {
  // Track page views
  if (process.client) {
    trackPageView(to.fullPath)
  }
})

Global middleware runs on every route change automatically. You create it by adding .global to the file name (e.g., analytics.global.ts). This is useful for analytics tracking, logging, or any logic that should apply application-wide without having to reference it in each page.

Rendering Modes

Configuring rendering in route rules

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // SSR - domyślne
    '/': { ssr: true },

    // SSG - pre-rendered at build time
    '/about': { prerender: true },
    '/blog/**': { prerender: true },

    // SPA - client-side only
    '/dashboard/**': { ssr: false },
    '/admin/**': { ssr: false },

    // ISR - Incremental Static Regeneration
    '/products/**': { swr: 3600 }, // Revalidate every hour
    '/api/products/**': { swr: 60 }, // API cache 1 minute

    // Static with revalidation
    '/pricing': {
      prerender: true,
      headers: { 'cache-control': 's-maxage=3600' },
    },

    // CDN cache
    '/static/**': {
      headers: { 'cache-control': 'public, max-age=31536000, immutable' },
    },

    // Redirect
    '/old-page': { redirect: '/new-page' },

    // CORS for API
    '/api/**': {
      cors: true,
      headers: {
        'Access-Control-Allow-Origin': '*',
      },
    },
  },
})

One of Nuxt's most powerful features is hybrid rendering. Instead of choosing a single rendering strategy for your entire application, you can configure it per route using routeRules in nuxt.config.ts. The main options are SSR (server-rendered on each request -- the default), SSG (pre-rendered at build time using prerender: true), SPA (client-side only rendering with ssr: false), and ISR (Incremental Static Regeneration using swr with a revalidation interval in seconds). You can also set cache headers, configure redirects, and enable CORS on specific routes -- all from one centralized configuration.

Pinia integration

Installation and configuration

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
})

Pinia is the official state management solution for Vue and integrates seamlessly with Nuxt. By adding the @pinia/nuxt module, Pinia is automatically set up with SSR support -- your stores work on both server and client, and the state is properly hydrated during page load.

Store definition

TSstores/user.ts
TypeScript
// stores/user.ts
import { defineStore } from 'pinia'

interface User {
  id: string
  name: string
  email: string
}

export const useUserStore = defineStore('user', () => {
  // State
  const user = ref<User | null>(null)
  const isLoading = ref(false)

  // Getters
  const isLoggedIn = computed(() => !!user.value)
  const displayName = computed(() => user.value?.name || 'Guest')

  // Actions
  async function fetchUser() {
    isLoading.value = true
    try {
      const data = await $fetch<User>('/api/me')
      user.value = data
    } catch {
      user.value = null
    } finally {
      isLoading.value = false
    }
  }

  function setUser(userData: User) {
    user.value = userData
  }

  function clearUser() {
    user.value = null
  }

  return {
    user,
    isLoading,
    isLoggedIn,
    displayName,
    fetchUser,
    setUser,
    clearUser,
  }
})

The example above uses the Composition API style of defining a Pinia store (also called the "setup store" syntax). You define state with ref, getters with computed, and actions as regular functions. Everything you return from the setup function becomes available when you use the store in your components. This approach feels natural if you are already familiar with Vue's Composition API.

Using store in components

Code
VUE
<script setup lang="ts">
const userStore = useUserStore()

// SSR-safe hydration
if (process.server) {
  await userStore.fetchUser()
}
</script>

<template>
  <div>
    <p v-if="userStore.isLoggedIn">
      Welcome, {{ userStore.displayName }}
    </p>
  </div>
</template>

When using Pinia stores in Nuxt, keep SSR hydration in mind. If you fetch data on the server (using process.server), Pinia will automatically serialize that state and send it to the client, so the store is already populated when the page loads in the browser. This avoids unnecessary duplicate requests and prevents hydration mismatches.

Nuxt Modules - popular extensions

@nuxt/ui - Official component library

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui'],
  ui: {
    icons: ['heroicons', 'lucide'],
  },
})
Code
VUE
<template>
  <!-- Buttons -->
  <UButton color="primary" size="lg">Click me</UButton>
  <UButton icon="i-heroicons-plus" />

  <!-- Forms -->
  <UInput v-model="email" placeholder="Email" />
  <USelect v-model="country" :options="countries" />
  <UTextarea v-model="message" rows="4" />

  <!-- Feedback -->
  <UAlert title="Success!" color="green" />
  <UBadge color="blue">New</UBadge>

  <!-- Navigation -->
  <UDropdown :items="menuItems">
    <UButton>Menu</UButton>
  </UDropdown>

  <!-- Overlays -->
  <UModal v-model="isOpen">
    <UCard>
      <template #header>Modal Title</template>
      Content here
    </UCard>
  </UModal>
</template>

Nuxt UI is the official component library built specifically for Nuxt. It provides a comprehensive set of beautifully designed, accessible components including buttons, inputs, selects, modals, dropdowns, tables, and more. Everything is built on top of Tailwind CSS and Headless UI, making customization straightforward. The library includes icon support from multiple icon sets (Heroicons, Lucide, etc.) and supports dark mode out of the box.

@nuxt/image - Image optimization

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/image'],
  image: {
    provider: 'vercel', // lub 'cloudinary', 'imgix', 'ipx'
    quality: 80,
    format: ['webp', 'avif'],
  },
})

The @nuxt/image module provides <NuxtImg> and <NuxtPicture> components that automatically optimize your images. It supports multiple providers (Vercel, Cloudinary, Imgix, or the built-in IPX), automatic format conversion to WebP or AVIF, responsive sizing, and lazy loading. This can dramatically reduce page load times, especially on image-heavy sites.

@nuxtjs/i18n - Internationalization

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'en', name: 'English', file: 'en.json' },
      { code: 'pl', name: 'Polski', file: 'pl.json' },
    ],
    defaultLocale: 'pl',
    langDir: 'locales/',
  },
})

The @nuxtjs/i18n module adds full internationalization support to your Nuxt application. It handles locale detection, route localization (prefixed or domain-based), lazy-loaded translation files, SEO meta tags per locale, and provides the useI18n composable for accessing translations in your components. It is the standard solution for building multilingual Nuxt applications.

@vueuse/nuxt - Collection of Vue composition utilities

Code
VUE
<script setup lang="ts">
// Auto-imported z VueUse
const { x, y } = useMouse()
const { isFullscreen, toggle } = useFullscreen()
const isDark = useDark()
const { copy } = useClipboard()
const { width, height } = useWindowSize()
</script>

VueUse is a collection of hundreds of essential Vue composition utilities covering browser APIs, sensors, state management, animations, and more. The @vueuse/nuxt module integrates it into Nuxt with auto-imports, so composables like useMouse, useFullscreen, useDark, useClipboard, and useWindowSize are available everywhere without manual imports. It is an incredibly useful toolkit that saves you from writing common utility code from scratch.

SEO and Meta Tags

Code
VUE
<script setup lang="ts">
// Podstawowe meta
useHead({
  title: 'Moja Strona',
  meta: [
    { name: 'description', content: 'Opis strony' },
    { name: 'keywords', content: 'nuxt, vue, typescript' },
  ],
  link: [
    { rel: 'canonical', href: 'https://example.com/page' },
  ],
  script: [
    { src: 'https://analytics.example.com/script.js', async: true },
  ],
})

// SEO-specific meta
useSeoMeta({
  title: 'Moja Strona',
  description: 'Opis strony dla SEO',
  ogTitle: 'Moja Strona - Open Graph',
  ogDescription: 'Opis dla social media',
  ogImage: 'https://example.com/og-image.jpg',
  ogType: 'website',
  twitterCard: 'summary_large_image',
  twitterTitle: 'Moja Strona',
  twitterDescription: 'Opis dla Twitter',
})

// Dynamic titles
const { data: post } = await useFetch('/api/post/1')
useHead({
  title: () => post.value?.title || 'Loading...',
})
</script>

Nuxt provides two main composables for managing SEO and meta tags. useHead gives you full control over the <head> section, including title, meta tags, link tags, and scripts. useSeoMeta is a more focused alternative with typed properties specifically for SEO-related meta tags, including Open Graph and Twitter Card metadata. Both composables are reactive -- you can pass getter functions that automatically update the head when your data changes. This is especially useful for dynamic pages where the title and description depend on fetched content.

Error Handling

Code
VUE
<!-- error.vue - Global error page -->
<script setup lang="ts">
interface Props {
  error: {
    statusCode: number
    statusMessage: string
    message: string
  }
}

const props = defineProps<Props>()

const handleError = () => clearError({ redirect: '/' })
</script>

<template>
  <div class="min-h-screen flex items-center justify-center">
    <div class="text-center">
      <h1 class="text-6xl font-bold text-gray-900">
        {{ error.statusCode }}
      </h1>
      <p class="mt-4 text-xl text-gray-600">
        {{ error.statusMessage || 'An error occurred' }}
      </p>
      <button
        @click="handleError"
        class="mt-8 px-6 py-3 bg-blue-600 text-white rounded-lg"
      >
        Go Home
      </button>
    </div>
  </div>
</template>

Nuxt handles errors at multiple levels. The error.vue file at the root of your project serves as the global error page -- it renders whenever an unhandled error occurs, whether it is a 404, 500, or any other status code. You can use createError in your pages or server routes to throw structured errors with specific status codes and messages. The clearError utility lets you recover from errors and redirect the user. For more granular handling, you can use the <NuxtErrorBoundary> component to catch errors within specific sections of your page without affecting the entire application.

Deployment

Vercel

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    preset: 'vercel',
  },
})

Deploying to Vercel is the simplest option. Just set the Nitro preset to 'vercel' and connect your repository. Vercel automatically detects Nuxt, runs the build, and deploys with edge functions for SSR routes. It supports all rendering modes including SSR, SSG, ISR, and SPA out of the box.

Cloudflare Pages

TSnuxt.config.ts
TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    preset: 'cloudflare-pages',
  },
})

Cloudflare Pages is another excellent option, especially if you want edge-first deployment with a global CDN. The 'cloudflare-pages' preset configures Nitro to run as Cloudflare Workers, giving you low-latency responses from data centers around the world.

Docker

Code
DOCKERFILE
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output .output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

For self-hosted deployments, Docker is a solid choice. The multi-stage build keeps the final image small by only including the .output directory. Nuxt 3's build output is a standalone Node.js server that runs with node .output/server/index.mjs -- no need to install dependencies in the production image. This makes it easy to deploy to any container orchestration platform like Kubernetes, Docker Swarm, or services like AWS ECS and Google Cloud Run.

Pricing

  • 100% free - MIT License
  • NuxtHub: Hosting with database - pay as you go
    • Free tier: 100K requests/month
    • Pro: $20/month - unlimited requests
  • Nuxt UI Pro: Premium components - $299 (lifetime)

Nuxt itself is completely free and open-source under the MIT License. You can build and deploy any application without paying anything. NuxtHub is an optional hosting platform by the Nuxt team that provides a database, key-value storage, and blob storage alongside your deployment -- it has a generous free tier and a $20/month pro plan. Nuxt UI Pro is a premium extension of the Nuxt UI component library with additional components like dashboards, landing pages, and documentation layouts -- it costs $299 for a lifetime license.

FAQ - Frequently asked questions

How does Nuxt differ from Vue CLI?

Vue CLI creates basic Vue applications (SPA), while Nuxt is a full framework offering SSR, SSG, file-based routing, auto-imports, server routes, and much more out-of-the-box. Nuxt is to Vue what Next.js is to React.

When should you choose Nuxt over Next.js?

Choose Nuxt if you prefer Vue.js and want the auto-imports and module ecosystem that Nuxt provides. Next.js is the better choice if you prefer React or need access to the larger React library ecosystem.

Is Nuxt suitable for large enterprise applications?

Yes. Nuxt is used by large companies like GitLab, Adobe, and Upwork. It offers TypeScript, modules, middleware, and full control over rendering and caching.

How to migrate from Nuxt 2 to Nuxt 3?

Nuxt 3 is a complete rewrite. The main changes include Vue 3 Composition API, the Nitro server engine, and a new module system. The team provides the nuxi upgrade tool and a detailed migration guide in the documentation.

Can I use Nuxt without SSR?

Yes. You can set ssr: false in nuxt.config.ts for the entire application or use routeRules for specific pages. This creates an SPA similar to a traditional Vue application.