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)
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.