Migrating from Nuxt 2 to Nuxt 3
What I learned rebuilding this site from scratch — and why the move was worth it.
This website used to run on Nuxt 2. Single page, no blog, no components — just text and a few logos. Simple, but the framework hit end-of-life at the end of 2024. Time for a proper rebuild.
What changed
Three things motivated the move:
- Get onto a maintained framework — Nuxt 2 is no longer receiving security updates
- Add a blog without a CMS — I wanted to write posts as Markdown files, not manage a database
- Refresh the design — the old site had no visual hierarchy, minimal accessibility, and a broken SEO setup (it was a client-side-only SPA with
ssr: false)
Nuxt 3 with @nuxt/content ticks all three boxes.
The SEO problem with the old site
The old config had ssr: false, which means Nuxt rendered a blank HTML shell on the server and populated it in the browser. Google can crawl SPAs, but it's unreliable and slow — the crawler needs to execute JavaScript before it can index any content.
With nuxt generate in Nuxt 3 (SSR enabled at build time), every page ships as fully pre-rendered HTML. Google reads it instantly, no JavaScript execution needed.
The migration
For a site this small, the migration was straightforward:
nuxt.config.js→nuxt.config.ts— typed config, new module API<Nuxt />→<slot />in layouts (no more<NuxtPage />needed in layouts)asyncData→useAsyncDatacomposable- Global styles from SASS to plain CSS with Tailwind directives
The trickiest part was getting @tailwindcss/typography right for dark mode. The prose-invert modifier handles most of it, but code block colours needed manual --tw-prose-* CSS variable overrides.
@nuxt/content is excellent
Writing posts as .md files in a content/ folder is the right workflow for a developer blog. Frontmatter handles metadata (title, description, date, tags) cleanly. Shiki handles syntax highlighting out of the box:
// This is automatically highlighted
const greeting = (name: string): string => {
return `Hello, ${name}!`
}
The query API is also clean and composable:
// Fetch 3 latest posts, pick only the fields you need
const { data } = await useAsyncData('posts', () =>
queryContent('/blog')
.sort({ date: -1 })
.limit(3)
.only(['title', 'description', 'date', 'tags', '_path'])
.find()
)
Reading time is calculated automatically. Strongly recommended.