Skip to main content
Back to Blog
tutorialJanuary 15, 202435 min read

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

Robert Fridzema

Fullstack Developer

Getting Started with Nuxt 3 - A Practical Guide

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:

  1. Vue - ref, computed, watch, onMounted, etc.
  2. Nuxt - useRoute, useFetch, navigateTo, etc.
  3. 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'
TypeScriptts
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:

  1. Start minimal - Add directories as you need them
  2. Embrace auto-imports - They're not magic, just convention
  3. Use TypeScript - The DX improvement is substantial
  4. Understand SSR - Most issues come from server/client differences
  5. 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.

#nuxt #vue #javascript
Share: