Nuxt 3 Essentials
Nuxt 3 Essentials Nuxt is Vue's meta-framework — the Next.js equivalent for the Vue ecosystem. Nuxt 3 rebuilt the framework on Vite and a new server engine call…
Nuxt 3 Essentials
Nuxt is Vue's meta-framework — the Next.js equivalent for the Vue ecosystem. Nuxt 3 rebuilt the framework on Vite and a new server engine called Nitro, adding universal rendering (SSR/SSG/hybrid), file-based routing, auto-imports, and a composables-first data-fetching model. If you're comfortable with Vue's Composition API, most of Nuxt is 'the same Vue, with conventions and a server layer bolted on' rather than a separate mental model to learn.
Project Structure & Auto-Imports
Nuxt scans specific directories and wires them up automatically — no manual imports for components, composables, or Vue APIs like `ref`/`computed`. This is convention over configuration: `pages/` becomes routes, `components/` are globally available, `composables/` and `utils/` are auto-imported anywhere.
my-nuxt-app/
pages/
index.vue # /
about.vue # /about
posts/
[slug].vue # /posts/:slug (dynamic segment)
index.vue # /posts
components/
AppHeader.vue # auto-imported as <AppHeader />
PostCard.vue
composables/
useCart.ts # auto-imported as useCart()
server/
api/
posts.get.ts # GET /api/posts
posts/[id].delete.ts # DELETE /api/posts/:id
middleware/
logger.ts
layouts/
default.vue
admin.vue
middleware/
auth.ts # route middleware, usable via definePageMeta
app.vue # root component
nuxt.config.ts<!-- pages/posts/[slug].vue -->
<script setup lang="ts">
// `ref`, `computed`, `useRoute`, `useFetch` are all auto-imported — no import lines needed
const route = useRoute()
const slug = route.params.slug as string
const { data: post, error } = await useAsyncData(`post-${slug}`, () =>
$fetch(`/api/posts/${slug}`),
)
if (error.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found' })
}
useSeoMeta({
title: post.value?.title,
description: post.value?.excerpt,
})
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<div v-html="post.content" />
</article>
</template>Data Fetching: useFetch & useAsyncData
`useFetch` and `useAsyncData` are SSR-aware: they run on the server during server rendering and serialize the result into the HTML payload, so the client doesn't re-fetch on hydration — this is the main reason to prefer them over a plain `onMounted` + `fetch`. `useFetch(url)` is sugar over `useAsyncData` + `$fetch` for the common case; reach for `useAsyncData` directly when the fetching logic is more than a single URL call.
<script setup lang="ts">
// useFetch — shorthand for a single endpoint
const { data: posts, pending, error, refresh } = await useFetch('/api/posts', {
query: { limit: 20 },
key: 'posts-list', // dedupe/cache key — defaults to a hash of url+options
server: true, // fetch during SSR (default)
lazy: false, // false = block navigation until resolved (default)
default: () => [], // value while pending, before first resolve
})
// useAsyncData — for composed/multi-step fetching logic
const { data: dashboard } = await useAsyncData('dashboard', async () => {
const [user, stats] = await Promise.all([
$fetch('/api/user'),
$fetch('/api/stats'),
])
return { user, stats }
})
// lazy: true — don't block navigation, show a loading state instead
const { data: comments, pending: commentsPending } = useFetch('/api/comments', {
lazy: true,
})
</script>
<template>
<div v-if="pending">Loading…</div>
<ul v-else>
<li v-for="post in posts" :key="post.id">{{ post.title }}</li>
</ul>
<button @click="refresh()">Reload</button>
</template>Use `$fetch` directly (not `useFetch`) for one-off calls that don't need to run during SSR — form submissions, button-triggered actions — since it skips the extra reactivity/caching machinery you don't need there.
Nitro Server Routes (server/api)
Nitro is Nuxt's server engine — it powers universal deployment (Node server, serverless functions, edge workers, static hosting) from the same codebase, and gives you a full backend inside `server/`. File naming conventions map to HTTP methods and routes automatically.
// server/api/posts.get.ts — GET /api/posts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const limit = Number(query.limit ?? 20)
const posts = await db.post.findMany({ take: limit })
return posts
})
// server/api/posts.post.ts — POST /api/posts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
if (!body.title) {
throw createError({ statusCode: 400, statusMessage: 'title is required' })
}
return await db.post.create({ data: body })
})
// server/api/posts/[id].delete.ts — DELETE /api/posts/:id
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
await db.post.delete({ where: { id } })
setResponseStatus(event, 204)
})
// server/middleware/logger.ts — runs on every request
export default defineEventHandler((event) => {
console.log(`[${event.node.req.method}] ${event.node.req.url}`)
})
// server/utils/db.ts — auto-imported inside server/, like composables for the client
export const db = createDbClient(useRuntimeConfig().databaseUrl)Rendering Modes
Nuxt configures rendering per-route in `nuxt.config.ts`, mixing SSR, static prerendering, and SPA-only pages in a single app — this is what 'hybrid rendering' means in Nuxt.
// nuxt.config.ts
export default defineNuxtConfig({
// Global default: universal rendering (SSR on first load, SPA-like navigation after)
ssr: true,
routeRules: {
'/': { prerender: true }, // SSG at build time
'/blog/**': { swr: 3600 }, // stale-while-revalidate, cached 1h
'/dashboard/**': { ssr: false }, // client-only rendering (SPA)
'/admin/**': { index: false }, // exclude from search engines
'/api/legacy/**': { proxy: 'https://old-api.example.com/**' },
},
nitro: {
preset: 'vercel', // or 'node-server', 'cloudflare-pages', 'netlify', etc.
},
})
// nuxt generate — full static build (every page prerendered, deployable as static files)
// nuxt build — universal build (SSR server + hybrid route rules)
// nuxt dev — dev server with HMRUniversal (SSR) — default. Server renders HTML on first request, client hydrates and takes over navigation.
Static (SSG) — `nuxt generate` or `routeRules` with `prerender: true`; every page built to static HTML ahead of time.
SPA — `ssr: false` per-route; client-only rendering, good for authenticated dashboards where SEO doesn't matter.
Hybrid — different `routeRules` per path in the same app, the main advantage of Nuxt 3's rendering model over an all-or-nothing choice.
Common Gotchas
Fetching in onMounted instead of useFetch — skips SSR entirely and causes a client-side waterfall/loading flash; use `useFetch`/`useAsyncData` so data is in the initial HTML.
Accessing browser-only APIs during SSR — `window`/`document` don't exist on the server; guard with `import.meta.client` (or the older `process.client`) or move the code into a `onMounted` hook.
Duplicate fetches from mismatched keys — `useFetch` calls with the same auto-generated key dedupe; giving two different calls the same explicit `key` unintentionally makes them share (and overwrite) cached state.
Putting secrets in runtimeConfig's public keys — only values under `runtimeConfig.public` are exposed to the client bundle; API keys/secrets belong at the top level of `runtimeConfig`, server-only.
Forgetting definePageMeta runs at compile time — its options (layout, middleware, keepalive) are statically extracted, so you can't reference runtime variables inside it.