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
- Zero konfiguracji - Działa od razu po instalacji z sensownymi domyślnymi ustawieniami
- Auto-imports - Komponenty, composables i utilities są automatycznie importowane
- Hybrid Rendering - SSR, SSG, ISR, SPA w jednej aplikacji
- Nitro Engine - Uniwersalny serwer działający na edge, serverless i tradycyjnych hostingach
- TypeScript first - Pełna obsługa TypeScript bez dodatkowej konfiguracji
- Developer Experience - Hot Module Replacement, Vue DevTools, świetne error messages
Nuxt vs Next.js vs SvelteKit
| Cecha | Nuxt 3 | Next.js 14 | SvelteKit |
|---|---|---|---|
| Framework bazowy | Vue 3 | React 18 | Svelte 4 |
| Rendering | SSR/SSG/ISR/SPA | SSR/SSG/ISR/SPA | SSR/SSG/SPA |
| File routing | Tak | Tak | Tak |
| Auto-imports | Pełne | Częściowe | Częściowe |
| Server engine | Nitro | Edge Runtime | Adaptery |
| TypeScript | Natywny | Natywny | Natywny |
| Bundle size | Mały | Średni | Najmniejszy |
| Learning curve | Średnia | Średnia | Niska |
| Ekosystem modułów | 200+ | Brak oficjalnych | ~50 |
| Edge deployment | Tak | Tak | Tak |
Instalacja i konfiguracja
Tworzenie nowego projektu
# 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 devStruktura projektu Nuxt 3
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.tsPodstawowa konfiguracja nuxt.config.ts
// 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
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 -->
<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
<!-- 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 -->
<template>
<div>
<h2>Analytics Dashboard</h2>
<AnalyticsChart />
</div>
</template>Data Fetching
useFetch - SSR-friendly fetch
<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
<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
<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
// 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),
},
}
})// 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
})// 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
// 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',
})
}
})// server/middleware/log.ts
export default defineEventHandler((event) => {
console.log(`[${new Date().toISOString()}] ${getMethod(event)} ${event.path}`)
})Auto-imports i Composables
Wbudowane auto-imports
<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
// 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,
}
}// 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 }
}// 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
<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 -->
<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 -->
<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 -->
<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 -->
<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
// 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 },
})
}
})// middleware/admin.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { user, isAdmin } = useAuth()
if (!isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Admin only.',
})
}
})// 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
<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
// 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
// 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
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
})Store definition
// 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
<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
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
ui: {
icons: ['heroicons', 'lucide'],
},
})<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
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/image'],
image: {
provider: 'vercel', // lub 'cloudinary', 'imgix', 'ipx'
quality: 80,
format: ['webp', 'avif'],
},
})@nuxtjs/i18n - Internationalization
// 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
<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
<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
<!-- 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
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'vercel',
},
})Cloudflare Pages
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'cloudflare-pages',
},
})Docker
# 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 /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
- Zero configuration - Works right out of the box with sensible default settings
- Auto-imports - Components, composables, and utilities are automatically imported
- Hybrid Rendering - SSR, SSG, ISR, SPA in a single application
- Nitro Engine - Universal server running on edge, serverless, and traditional hosting
- TypeScript first - Full TypeScript support without additional configuration
- Developer Experience - Hot Module Replacement, Vue DevTools, excellent error messages
Nuxt vs Next.js vs SvelteKit
| Feature | Nuxt 3 | Next.js 14 | SvelteKit |
|---|---|---|---|
| Base framework | Vue 3 | React 18 | Svelte 4 |
| Rendering | SSR/SSG/ISR/SPA | SSR/SSG/ISR/SPA | SSR/SSG/SPA |
| File routing | Yes | Yes | Yes |
| Auto-imports | Full | Partial | Partial |
| Server engine | Nitro | Edge Runtime | Adapters |
| TypeScript | Native | Native | Native |
| Bundle size | Small | Medium | Smallest |
| Learning curve | Medium | Medium | Low |
| Module ecosystem | 200+ | No official ones | ~50 |
| Edge deployment | Yes | Yes | Yes |
Installation and configuration
Creating a new project
# 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 devNuxt 3 project structure
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.tsBasic nuxt.config.ts configuration
// 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
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 -->
<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
<!-- 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 -->
<template>
<div>
<h2>Analytics Dashboard</h2>
<AnalyticsChart />
</div>
</template>Data Fetching
useFetch - SSR-friendly fetch
<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
<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
<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
// 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),
},
}
})// 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
})// 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
// 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',
})
}
})// 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
<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
// 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.
// 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 }
}// 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
<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 -->
<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 -->
<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 -->
<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 -->
<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
// 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 },
})
}
})// middleware/admin.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { user, isAdmin } = useAuth()
if (!isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Admin only.',
})
}
})// 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
<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
// 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
// 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
// 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
// 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
<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
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
ui: {
icons: ['heroicons', 'lucide'],
},
})<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
// 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
// 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
<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
<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
<!-- 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
// 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
// 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
# 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 /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.