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
/aboutpage and an/instagramlinks 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'vstype: 'data'— pages get URLs, data files are just structured contentsourceuses glob patterns to define what files belong to a collectionschemais 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.vue | pages/blog/[slug].vue |
pages/blog/tag/_tag.vue | pages/blog/tag/[tag].vue |
pages/blog/author/_author.vue | pages/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:
- Reduce dependency footprint
- Have full control over the CSS output
- 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.