Getting Started with Nuxt 3 - A Practical Guide
Learn how to build modern web applications with Nuxt 3, from project setup to deployment. Includes tips from migrating a real project.
Robert Fridzema
Fullstack Developer

Introduction
When I decided to rebuild my portfolio from scratch, I chose Nuxt 3. Not because it was trendy, but because after years of working with Nuxt 2, I'd hit its limitations too many times. The Composition API was bolted on, TypeScript support felt like an afterthought, and the module ecosystem was showing its age.
Nuxt 3 fixed all of that. It's built on Vue 3 from the ground up, has first-class TypeScript support, and introduces Nitro - a server engine that makes deployment almost trivially simple.
This guide covers everything I learned migrating my portfolio. If you're starting fresh or considering a migration, this will save you hours of trial and error.
Who This Guide Is For
This tutorial assumes you have:
- Basic familiarity with Vue (components, reactivity, templates)
- Some JavaScript/TypeScript experience
- Node.js 18+ installed on your machine
- Comfort with the command line
If you're completely new to Vue, I'd recommend the official Vue documentation first. Nuxt adds conventions on top of Vue - understanding Vue makes those conventions intuitive.
Prerequisites
Before we start, let's ensure your environment is ready:
# Check Node version (18+ required) node --version # I recommend using a version manager nvm use 20 # or fnm, volta, etc.
You'll also want a good code editor. VS Code with the Vue - Official extension (formerly Volar) provides excellent Nuxt 3 support, including auto-imports awareness and TypeScript integration.
Project Setup
Creating Your First Project
The Nuxt team provides nuxi, a CLI tool for scaffolding and managing projects:
npx nuxi init my-project cd my-project npm install
This creates a minimal project structure. The nuxi init command is intentionally minimal - Nuxt 3 takes a "start simple, add what you need" approach.
Let's start the development server:
npm run dev
Open http://localhost:3000 and you'll see the Nuxt welcome page. That's it - you have a working Nuxt 3 application.
Understanding the Initial Structure
After initialization, your project looks like this:
my-project/ ├── .nuxt/ # Generated during development (gitignored) ├── node_modules/ # Dependencies ├── public/ # Static assets (served at root) ├── server/ # Server-side code (API routes, middleware) ├── app.vue # Root component ├── nuxt.config.ts # Nuxt configuration ├── package.json └── tsconfig.json # TypeScript config (auto-generated)
The beauty of Nuxt 3 is that most directories are optional. The framework detects what you add and configures itself accordingly.
Directory Structure Deep-Dive
As your project grows, you'll add more directories. Here's what each one does:
pages/ - File-based Routing
Create this directory to enable the router:
pages/ ├── index.vue # / ├── about.vue # /about ├── contact.vue # /contact └── blog/ ├── index.vue # /blog └── [slug].vue # /blog/:slug (dynamic route)
Each .vue file becomes a route automatically. No router configuration needed.
components/ - Auto-imported Components
Any component in this directory is available everywhere without importing:
components/ ├── TheHeader.vue # <TheHeader /> ├── TheFooter.vue # <TheFooter /> ├── ui/ │ ├── Button.vue # <UiButton /> │ └── Card.vue # <UiCard /> └── blog/ └── PostCard.vue # <BlogPostCard />
Notice how nested folders become part of the component name. This prevents naming collisions and makes the code self-documenting.
composables/ - Shared Logic
Reusable composition functions go here and are auto-imported:
// composables/useCounter.ts export function useCounter(initial = 0) { const count = ref(initial) const increment = () => count.value++ const decrement = () => count.value-- return { count, increment, decrement } }
Use it anywhere without imports:
<script setup> const { count, increment } = useCounter(10) </script>
layouts/ - Page Wrappers
Layouts wrap pages with common elements:
<!-- layouts/default.vue --> <template> <div class="min-h-screen"> <TheHeader /> <main> <slot /> </main> <TheFooter /> </div> </template>
Pages use the default layout automatically. Specify a different one with:
<script setup> definePageMeta({ layout: 'minimal' }) </script>
middleware/ - Route Guards
Run code before navigation:
// middleware/auth.ts export default defineNuxtRouteMiddleware((to, from) => { const user = useUser() if (!user.value && to.path !== '/login') { return navigateTo('/login') } })
Apply middleware per-page:
<script setup> definePageMeta({ middleware: 'auth' }) </script>
Or globally in nuxt.config.ts with the .global.ts suffix.
plugins/ - App Initialization
Code that runs when your app starts:
// plugins/analytics.client.ts export default defineNuxtPlugin(() => { // .client.ts = only runs in browser console.log('Analytics initialized') })
The .client.ts or .server.ts suffix controls where plugins run.
server/ - Backend Logic
Nuxt 3's Nitro engine provides a full backend:
// server/api/posts.ts export default defineEventHandler(async (event) => { const posts = await fetchPostsFromDatabase() return posts })
This creates an API endpoint at /api/posts. Nitro supports middleware, utilities, and database connections too.
content/ - Markdown Content
When using @nuxt/content, this directory holds your Markdown files:
content/ ├── blog/ │ ├── getting-started.md │ └── advanced-tips.md └── projects/ └── portfolio.md
More on this in the @nuxt/content section below.
Key Concepts
File-based Routing In Depth
Dynamic Routes
Brackets create dynamic segments:
pages/ ├── users/ │ ├── [id].vue # /users/123 │ └── [id]/ │ └── posts.vue # /users/123/posts
Access the parameter with useRoute():
<script setup> const route = useRoute() const userId = route.params.id </script>
Catch-All Routes
Use [...slug] for flexible matching:
pages/ └── blog/ └── [...slug].vue # /blog, /blog/2024, /blog/2024/01/post
The slug is an array:
<script setup> const route = useRoute() // /blog/2024/01/post -> ['2024', '01', 'post'] console.log(route.params.slug) </script>
Nested Routes
Create parent-child relationships with folders:
pages/ └── settings/ ├── index.vue # /settings ├── profile.vue # /settings/profile └── security.vue # /settings/security
For nested layouts, use settings.vue alongside settings/:
<!-- pages/settings.vue --> <template> <div class="settings-layout"> <SettingsNav /> <NuxtPage /> <!-- Child pages render here --> </div> </template>
Auto-imports Explained
Nuxt auto-imports from multiple sources:
- Vue -
ref,computed,watch,onMounted, etc. - Nuxt -
useRoute,useFetch,navigateTo, etc. - Your code - Components, composables, utilities
You can customize this in nuxt.config.ts:
export default defineNuxtConfig({ imports: { // Add directories to auto-import from dirs: ['stores', 'utils'], }, components: { // Configure component auto-import dirs: [ { path: '~/components', pathPrefix: false }, { path: '~/components/ui', prefix: 'Ui' }, ], }, })
To see what's available, check .nuxt/imports.d.ts after running the dev server.
Explicit Imports
If you prefer explicit imports (for editor support or clarity), they still work:
<script setup> import { ref, computed } from 'vue' import { useRoute } from 'vue-router' const route = useRoute() </script>
Nuxt doesn't prevent normal imports - auto-imports are additive.
Data Fetching
Nuxt provides two main composables for data fetching. Understanding when to use each is critical.
useFetch - Simple Cases
For straightforward API calls:
<script setup> const { data: posts, status, error, refresh } = await useFetch('/api/posts') </script> <template> <div v-if="status === 'pending'">Loading...</div> <div v-else-if="error">Error: {{ error.message }}</div> <ul v-else> <li v-for="post in posts" :key="post.id">{{ post.title }}</li> </ul> </template>
useFetch is a convenience wrapper around useAsyncData + $fetch. It:
- Runs on server during SSR
- Deduplicates requests
- Caches responses
- Re-fetches on navigation
useAsyncData - Complex Cases
When you need more control:
const { data: enrichedPost } = await useAsyncData( 'enriched-post', // Unique key for caching async () => { // Fetch multiple things const [post, author, comments] = await Promise.all([ $fetch(`/api/posts/${route.params.id}`), $fetch(`/api/posts/${route.params.id}/author`), $fetch(`/api/posts/${route.params.id}/comments`), ]) // Transform the data return { ...post, author, commentCount: comments.length, hasComments: comments.length > 0, } } )
The key difference: useAsyncData lets you run arbitrary async code, while useFetch is specifically for HTTP requests.
Error Handling
Both composables return an error ref:
<script setup> const { data, error } = await useFetch('/api/posts') // Handle specific error types if (error.value) { if (error.value.statusCode === 404) { throw createError({ statusCode: 404, statusMessage: 'Post not found' }) } } </script>
For app-wide error handling, create error.vue in your project root:
<!-- error.vue --> <script setup> const props = defineProps<{ error: { statusCode: number statusMessage: string } }>() const handleError = () => clearError({ redirect: '/' }) </script> <template> <div class="error-page"> <h1>{{ error.statusCode }}</h1> <p>{{ error.statusMessage }}</p> <button @click="handleError">Go Home</button> </div> </template>
Refreshing Data
Both composables return a refresh function:
<script setup> const { data: posts, refresh, status } = await useFetch('/api/posts') // Manual refresh const onRefreshClick = () => refresh() // Watch for changes and refresh watch(someFilter, () => refresh()) </script>
For polling, use the watch option:
const { data } = await useFetch('/api/status', { watch: false, // Don't re-fetch on param changes }) // Manual polling const poll = setInterval(() => refresh(), 5000) onUnmounted(() => clearInterval(poll))
State Management
useState - Simple Shared State
For basic shared state, useState is built-in and SSR-safe:
// composables/useUser.ts export function useUser() { return useState<User | null>('user', () => null) }
The key ('user') ensures the same state is shared across components:
<!-- In any component --> <script setup> const user = useUser() const login = async (credentials) => { user.value = await $fetch('/api/auth/login', { method: 'POST', body: credentials }) } </script>
Pinia - Complex State
For larger applications, Pinia integrates seamlessly:
npm install @pinia/nuxt pinia
// nuxt.config.ts export default defineNuxtConfig({ modules: ['@pinia/nuxt'], })
Create stores:
// stores/cart.ts export const useCartStore = defineStore('cart', () => { const items = ref<CartItem[]>([]) const total = computed(() => items.value.reduce((sum, item) => sum + item.price * item.quantity, 0) ) function addItem(product: Product) { const existing = items.value.find(i => i.id === product.id) if (existing) { existing.quantity++ } else { items.value.push({ ...product, quantity: 1 }) } } function removeItem(id: string) { items.value = items.value.filter(i => i.id !== id) } return { items, total, addItem, removeItem } })
Use in components:
<script setup> const cart = useCartStore() </script> <template> <div> <p>Total: {{ cart.total }}</p> <button @click="cart.addItem(product)">Add to Cart</button> </div> </template>
Working with @nuxt/content
For content-heavy sites like blogs, @nuxt/content is incredible. It turns your Markdown files into a queryable database.
Setup
npm install @nuxt/content
// nuxt.config.ts export default defineNuxtConfig({ modules: ['@nuxt/content'], content: { highlight: { theme: { default: 'github-dark', light: 'github-light', dark: 'github-dark', }, langs: ['javascript', 'typescript', 'vue', 'bash', 'json'], }, markdown: { toc: { depth: 3, searchDepth: 3 }, }, }, })
Content Structure
Create Markdown files with frontmatter:
--- title: My First Post description: An introduction to my blog date: 2024-01-15 tags: - intro - blog published: true --- ## Welcome This is my first blog post...
Querying Content
The queryContent helper provides a fluent API:
// Fetch all published posts, sorted by date const { data: posts } = await useAsyncData('posts', () => queryContent('blog') .where({ published: true }) .sort({ date: -1 }) .find() ) // Fetch a single post by path const { data: post } = await useAsyncData('post', () => queryContent('blog') .where({ _path: `/blog/${route.params.slug}` }) .findOne() ) // Search with full-text const { data: results } = await useAsyncData('search', () => queryContent('blog') .where({ $text: { $search: searchQuery } }) .find() )
Rendering Content
Use the <ContentDoc> component for automatic rendering:
<template> <ContentDoc :path="`/blog/${$route.params.slug}`"> <template #default="{ doc }"> <h1>{{ doc.title }}</h1> <ContentRenderer :value="doc" /> </template> <template #not-found> <p>Post not found</p> </template> </ContentDoc> </template>
Or render manually with more control:
<script setup> const route = useRoute() const { data: post } = await useAsyncData('post', () => queryContent(route.path).findOne() ) </script> <template> <article v-if="post"> <h1>{{ post.title }}</h1> <time>{{ new Date(post.date).toLocaleDateString() }}</time> <ContentRenderer :value="post" /> </article> </template>
MDC Syntax
Nuxt Content supports MDC (Markdown Components) - use Vue components inside Markdown:
# My Post Here's a regular paragraph. ::alert{type="warning"} This is a warning alert component! :: And back to regular markdown. ::code-group ```js [JavaScript] const hello = 'world'
const hello: string = 'world'
::
The `::component-name` syntax renders Vue components from your `components/content/` directory. ## SEO and Meta Tags Nuxt 3 provides excellent SEO tooling out of the box. ### Global Meta Set defaults in `nuxt.config.ts`: ```typescript export default defineNuxtConfig({ app: { head: { htmlAttrs: { lang: 'en' }, title: 'My Site', meta: [ { charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { name: 'description', content: 'Default description' }, ], link: [ { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, ], }, }, })
Per-Page Meta with useSeoMeta
The recommended approach for SEO meta tags:
<script setup> const post = await getPost() useSeoMeta({ title: post.title, description: post.description, ogTitle: post.title, ogDescription: post.description, ogImage: post.thumbnail, ogType: 'article', twitterCard: 'summary_large_image', twitterTitle: post.title, twitterDescription: post.description, twitterImage: post.thumbnail, }) </script>
useSeoMeta is fully typed - your editor will autocomplete all valid meta tags.
useHead for Everything Else
For non-meta head elements:
<script setup> useHead({ title: 'Page Title', titleTemplate: '%s | My Site', link: [ { rel: 'canonical', href: 'https://example.com/page' }, ], script: [ { src: 'https://example.com/script.js', defer: true }, ], }) </script>
Dynamic Titles
Use titleTemplate for consistent branding:
// nuxt.config.ts export default defineNuxtConfig({ app: { head: { titleTemplate: '%s | Robert Fridzema', }, }, })
Then in pages:
<script setup> useHead({ title: 'About' }) // Renders: "About | Robert Fridzema" </script>
Deployment
Nuxt 3's Nitro engine makes deployment flexible. The same codebase can deploy anywhere.
Static Generation (Vercel, Netlify, GitHub Pages)
For fully static sites:
npm run generate
This pre-renders all pages to HTML. Deploy the .output/public directory.
For Vercel, just connect your repo - it detects Nuxt automatically:
// vercel.json (optional, for customization) { "buildCommand": "npm run generate", "outputDirectory": ".output/public" }
For Netlify:
# netlify.toml [build] command = "npm run generate" publish = ".output/public"
Node.js Server
For dynamic features (SSR, API routes):
npm run build node .output/server/index.mjs
The server runs on port 3000 by default. Use PM2 or similar for production:
pm2 start .output/server/index.mjs --name my-app
Edge Deployment (Cloudflare Workers, Deno Deploy)
Nitro supports edge runtimes:
// nuxt.config.ts export default defineNuxtConfig({ nitro: { preset: 'cloudflare-pages', // or 'deno', 'vercel-edge', etc. }, })
Then build normally:
npm run build
Docker
For containerized deployments:
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"]
Common Pitfalls and Solutions
After migrating and building several Nuxt 3 projects, here are the issues I've hit most often:
Hydration Mismatches
Problem: Console shows "Hydration text mismatch" or similar errors.
Cause: Server and client render different content - usually dates, random IDs, or browser-only APIs.
Solution: Use <ClientOnly> for browser-specific content:
<template> <ClientOnly> <p>Current time: {{ new Date().toLocaleString() }}</p> <template #fallback> <p>Loading...</p> </template> </ClientOnly> </template>
Or generate consistent values:
<script setup> // Bad - different on server vs client const id = Math.random().toString(36) // Good - consistent const id = useId() // Nuxt's built-in </script>
Accessing window/document
Problem: window is not defined or document is not defined.
Cause: Code runs on server where browser APIs don't exist.
Solution: Check the environment or use lifecycle hooks:
<script setup> // Option 1: Check environment if (process.client) { window.scrollTo(0, 0) } // Option 2: Use onMounted (only runs client-side) onMounted(() => { const width = window.innerWidth }) // Option 3: Use VueUse helpers import { useWindowSize } from '@vueuse/core' const { width, height } = useWindowSize() </script>
Missing await on useFetch/useAsyncData
Problem: Data is undefined when it should be loaded.
Cause: Forgot await - the composables return immediately with pending status.
Solution: Always await in <script setup>:
<script setup> // Wrong - data might be null initially const { data } = useFetch('/api/posts') // Right - suspends until loaded const { data } = await useFetch('/api/posts') </script>
Module Not Found After Adding Directory
Problem: Created composables/ or utils/ but imports don't work.
Cause: Nuxt needs to rescan directories.
Solution: Restart the dev server:
# Stop dev server (Ctrl+C) npm run dev
The .nuxt directory caches structure - restarting regenerates it.
TypeScript Errors in Fresh Projects
Problem: IDE shows errors even though code runs fine.
Cause: TypeScript definitions not generated yet.
Solution: Run the prepare command:
npm run postinstall # or npx nuxi prepare
This generates .nuxt/nuxt.d.ts with all type definitions.
Circular Dependencies in Composables
Problem: Strange behavior or infinite loops when composables call each other.
Cause: Composable A imports B, which imports A.
Solution: Restructure to avoid cycles, or use lazy imports:
// Instead of direct import import { useAuth } from './useAuth' // Use dynamic import where needed const useAuth = () => import('./useAuth').then(m => m.useAuth())
Conclusion
Nuxt 3 represents a significant leap forward. The combination of Vue 3's Composition API, automatic imports, and Nitro's universal deployment makes it my go-to for web applications.
Key takeaways from my experience:
- Start minimal - Add directories as you need them
- Embrace auto-imports - They're not magic, just convention
- Use TypeScript - The DX improvement is substantial
- Understand SSR - Most issues come from server/client differences
- Check the docs - Nuxt's documentation is excellent and actively maintained
The migration from Nuxt 2 to 3 was more rewrite than upgrade for me, but the result is a faster, more maintainable codebase. If you're starting fresh, you're in a great position - Nuxt 3 is mature, well-documented, and production-ready.
The ecosystem is still growing, but the core is solid. For content sites, portfolios, blogs, and full-stack applications, I can't think of a better choice in the Vue ecosystem.
Building something with Nuxt 3? I'd love to hear about it - reach out on the about page.
Related Articles

Building Production Electron Apps with Vue 3 and Azure SSO
A comprehensive guide to building secure, enterprise-ready Electron applications with Vue 3, TypeScript, and Azure AD authentication.

PDF Processing in the Browser with pdf-lib and Tesseract.js
Build powerful PDF manipulation and OCR capabilities entirely in the browser, keeping sensitive documents private and eliminating server dependencies.

Laravel 12 Starter Kit: Testing, Static Analysis & Quality Gates
How to set up a modern Laravel 12 project with comprehensive testing, static analysis, and automated quality gates from day one.