Back to news
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 generation β€” `nuxt 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