Back to news
3 min DevNuxtWebFirebase

How I rebuilt my personal site with Nuxt 4 and Firebase

My old site was a single static HTML file. Here's how and why I rebuilt it with Nuxt 4, Tailwind CSS, Firebase Hosting, and a fully custom multilingual system covering 10 languages.

The old site

For a long time, my personal site was a single index.html file. Minimal, fast, and easy to maintain. It did the job — but as I started building more projects and wanted a proper news feed, it wasn't going to scale.

I decided to do a full rebuild.

Why Nuxt 4

I chose Nuxt 4 for a few specific reasons:

  • Static generationnuxt generate produces a fully static output compatible with Firebase Hosting
  • File-based routing — Article pages at /news/[slug] just work with zero configuration
  • TypeScript-first — The entire codebase is typed, from server routes to composables
  • SSR + hydration — Pages are pre-rendered on the server and hydrated on the client, which is ideal for SEO

The stack

  • Nuxt 4 (compatibility version 4) — App directory structure, SSR with static generation
  • Tailwind CSS — Utility-first styling with no component library overhead
  • Firebase Hosting — Global CDN, near-zero config deploys with firebase deploy
  • Custom i18n — A hand-rolled composable using vue-i18n directly, no module needed
  • Nitro server routes — Articles are stored as local JSON files and served via server/api/ endpoints

The multilingual system

The site supports 10 languages: English, French, German, Spanish, Italian, Dutch, Norwegian, Polish, Portuguese, and Turkish.

I deliberately avoided @nuxtjs/i18n. For a personal site with this scope, the module brings too much weight and complexity. Instead, I wrote a useI18n() composable that wraps vue-i18n directly and reads from a locale cookie. Switching languages is instant and doesn't require a page reload.

Articles as local JSON

Each article is a single JSON file in content/articles/. The schema is simple:

  • slug — the URL key
  • date — ISO date string
  • tags — array of strings
  • url — optional external link
  • translations — an object with one entry per locale, each containing title, summary, and body (Markdown text)
This means every article is fully translated at the content layer. The page component just picks the right translation based on the current locale — no external service, no API call.

SEO and prerendering

For each article to be properly indexed, it needs its own static HTML file. A nitro:config hook reads the content/articles/ directory before the Vite build and adds every /news/[slug] route to the prerender list.

Each article page uses useSeoMeta() for meta tags and useHead() for canonical + hreflang links across all 10 locales. Google can index every article in every language from a single URL.

What I'd do differently

The main thing I underestimated was hydration. With SSR, any mismatch between what the server renders and what the client expects causes Vue hydration warnings. The most common culprit: conditional rendering based on client-only state (like a cookie or localStorage). The fix is wrapping those blocks in <ClientOnly> or initializing state carefully from the server.

What's next

The rebuild is complete. The site now has proper routing, a news feed, and full multilingual support. I plan to keep publishing here — new Reloadium launches, product thinking, and the occasional technical deep-dive.

Share