← Writing
nuxtweb development

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:

  1. Get onto a maintained framework — Nuxt 2 is no longer receiving security updates
  2. Add a blog without a CMS — I wanted to write posts as Markdown files, not manage a database
  3. 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.jsnuxt.config.ts — typed config, new module API
  • <Nuxt /><slot /> in layouts (no more <NuxtPage /> needed in layouts)
  • asyncDatauseAsyncData composable
  • 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.