Migrating to Nuxt 3 and Nuxt Content 3

A deep dive into migrating my personal site from Nuxt 2 + Content v2 to Nuxt 3.21 + Content 3.14

Migrating to Nuxt 3 and Nuxt Content 3

After some time running my personal site on Nuxt 2 with the Content v2 module, I decided it was time to modernize the stack. Here's a comprehensive breakdown of what changed, what broke, and how I fixed it.

Why Migrate?

Nuxt 3 brings significant improvements:

  • Better performance with the Nitro engine and auto-generated server functions
  • Composition API by default with cleaner <script setup> syntax
  • TypeScript native support without extra configuration
  • Smaller bundle sizes thanks to tree-shaking and the new bundler

Nuxt Content 3 is a complete rewrite with SQL-backed queries, making content fetching dramatically faster and more flexible.

The Starting Point

My site was running:

  • Nuxt 2.15 with @nuxt/content@2.6.0
  • Tailwind CSS for utility-first styling
  • Custom canvas components for animated text (MadjozoAnimation, TextWave)
  • A blog at /blog/[slug] with tag and author filtering
  • A static /about page and an /instagram links page

Phase 1: Dependency Upgrade

The first step was updating package.json:

{
  "devDependencies": {
    "@nuxt/content": "^3.0.0",
    "nuxt": "^3.15.0",
    "typescript": "^5.7.0",
    "zod": "^3.24.0"
  }
}

Content v3 requires SQLite for its database. On Node 22.5+, you can use the native node:sqlite module, but for broader compatibility I installed better-sqlite3:

pnpm add better-sqlite3 --save-dev

Phase 2: Collections Configuration

The biggest conceptual shift in Content v3 is collections. Instead of queryContent('blog'), you now define typed collections in content.config.ts:

import { defineContentConfig, defineCollection, z } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    blog: defineCollection({
      type: 'page',
      source: 'blog/*.md',
      schema: z.object({
        title: z.string(),
        description: z.string(),
        img: z.string().optional(),
        tags: z.array(z.string()).optional(),
        author: z
          .object({
            name: z.string(),
            bio: z.string()
          })
          .optional()
      })
    }),
    tags: defineCollection({
      type: 'data',
      source: 'tags/*.md'
    }),
    pages: defineCollection({
      type: 'page',
      source: '*.md'
    })
  }
})

Key differences from v2:

  • type: 'page' vs type: 'data' — pages get URLs, data files are just structured content
  • source uses glob patterns to define what files belong to a collection
  • schema is validated with Zod at build time

Phase 3: Query API Changes

v2 Syntax (Old)

const article = await $content('articles', params.slug).fetch()
const [prev, next] = await $content('articles')
  .only(['title', 'slug'])
  .sortBy('createdAt', 'asc')
  .surround(params.slug)
  .fetch()

v3 Syntax (New)

const { data: post } = await useAsyncData(() =>
  queryCollection('blog')
    .path('/blog/' + slug)
    .first()
)

const { data: surround } = await useAsyncData(() =>
  queryCollectionItemSurroundings('blog', '/blog/' + slug, {
    fields: ['title', 'path']
  })
)

Major changes:

  • queryContent()queryCollection('collectionName')
  • _path.path (no underscore)
  • sortBy('createdAt', 'asc').order('createdAt', 'ASC')
  • surround()queryCollectionItemSurroundings() (separate composable!)
  • fetch().all() or .first()

Phase 4: Component Changes

<ContentDoc> is Gone

In v3, all content is rendered with <ContentRenderer>:

<template>
  <ContentRenderer v-if="page" :value="page" />
</template>

The <ContentDoc>, <ContentList>, <ContentQuery> and <ContentNavigation> components from v2 are removed.

Document Driven Mode

The automatic page generation from markdown files is gone. You now need explicit pages:

<!-- pages/[...slug].vue -->
<script setup>
const route = useRoute()
const { data: page } = await useAsyncData(route.path, () =>
  queryCollection('pages').path(route.path).first()
)
</script>
<template>
  <ContentRenderer v-if="page" :value="page" />
</template>

This gives you full control over layouts and error handling.

Phase 5: Routing Changes

Nuxt 3 uses bracket syntax for dynamic routes:

Old (Nuxt 2)New (Nuxt 3)
pages/blog/_slug.vuepages/blog/[slug].vue
pages/blog/tag/_tag.vuepages/blog/tag/[tag].vue
pages/blog/author/_author.vuepages/blog/author/[author].vue

And the navigateTo() composable replaces raw redirects:

<script setup>
navigateTo('/instagram', { replace: true })
</script>

Phase 6: Styling — Goodbye Tailwind

I chose to remove Tailwind entirely and switch to hand-written SCSS. This was a deliberate design decision to:

  1. Reduce dependency footprint
  2. Have full control over the CSS output
  3. Use semantic class names instead of utility soup

All components now use <style scoped lang="scss">:

.article-card {
  display: flex;
  gap: 1rem;
  padding: 1rem;
  border: 1px solid rgba(255, 255, 255, 0.1);

  &:hover {
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2);
  }
}

CSS custom properties in main.scss handle the theme:

html {
  --color-black: #070707;
  --color-white: #f7f7f7;
  --max-content-width: 900px;
}

Phase 7: What Broke and How I Fixed It

Component Auto-Registration

In v2, components/content/ was automatically global for markdown rendering. In v3, these components are still auto-registered for markdown within <ContentRenderer>, but if you use them outside markdown you must register them manually.

Fix: My TextWave and MadjozoAnimation are only used inside markdown, so no changes needed.

_path Renamed to path

All internal fields lost their underscore prefix. Any code referencing post._path must change to post.path.

searchContent() Dropped

The old search API is gone. In v3, use queryCollectionSearchSections():

const results = await queryCollectionSearchSections('blog', {
  ignoredTags: []
})

I decided to defer live search to a later phase.

File Structure After Migration

├── components/
│   ├── content/          # Markdown components (TextWave, MadjozoAnimation, ProseH1)
│   ├── global/           # Global components (PrevNext, VideoLoop, MadjozoCanvas)
│   ├── shell/            # Header & Footer
│   └── ...               # Shared UI (Author, AppSearchInput)
├── content/
│   ├── blog/             # Blog posts
│   ├── tags/             # Tag metadata
│   ├── about.md          # About page
│   └── instagram.md      # Links page
├── pages/
│   ├── index.vue         # Article list
│   ├── i.vue             # Redirect to /instagram
│   ├── [...slug].vue     # Static pages catch-all
│   ├── blog/
│   │   ├── [slug].vue    # Article detail
│   │   ├── author/
│   │   │   └── [author].vue
│   │   └── tag/
│   │       └── [tag].vue
├── content.config.ts     # Collection definitions
└── nuxt.config.ts        # Module config

Results

The migration is complete. The site is now:

  • Faster: SQL-backed content queries + Nitro server engine
  • More maintainable: Type-safe collections + explicit routing
  • Lighter: No Tailwind, smaller bundle
  • Future-proof: On the latest Nuxt 3 and Content 3 LTS track

Resources


This post was written as part of the migration itself — meta, I know.