Porting to Nuxt 3: +7% Larger Than React, the Only Regression in the Series (and Why)
Same spec, same data. On plain Vue 3, the landing page was 28.76 kB gzip (−41% vs React). I ported it to Nuxt 3 expecting a small fixed cost — maybe 30-35 kB — and it came out to 52.01 kB, +7% over React. The meta-framework tax is real, and the SPA mode can't opt out of it cleanly.
Entry #5 in the framework comparison series. So far: React (49 kB), Vue (28.76 kB), Svelte (18.92 kB), Solid (8.33 kB). Every port so far has been smaller than React. Until now.
Nuxt 3 on the same spec produced 52.01 kB gzip, making it the first (and so far only) port in the series that's larger than the React baseline. That's a regression of 23 kB from plain Vue. Here's where it went.
🔗 Live demo: https://sen.ltd/portfolio/portfolio-app-nuxt/
📦 GitHub: https://github.com/sen-ltd/portfolio-app-nuxt
Configuration: Nuxt 3 in SPA mode, static generation for deployment. The SSR story is unused — this is an apples-to-apples bundle comparison, not a framework evaluation.
What I expected
I thought "Vue plus a little meta-framework overhead" would land in the 30-35 kB range. Some kind of minor Nuxt bootstrap, maybe a small runtime, call it +5 kB to plain Vue. Instead:
dist/public/_nuxt/entry.wkgsnRPK.css 13.36 kB │ gzip: 2.85 kB
dist/public/_nuxt/8rkIqfi-.js 161.43 kB │ gzip: 52.01 kB
52 kB is almost double plain Vue's 28.76 kB. So where did +23 kB come from?
The culprits: vue-router and @unhead
Nuxt always ships vue-router and @unhead/vue in the bundle, even in SPA mode, even for apps that have exactly one page and no head manipulation. Those two packages together are roughly 18 kB of the delta:
node_modules/vue-router/ → ~10 kB gzip
node_modules/@unhead/vue/ → ~8 kB gzip
This app is a single page (pages/index.vue) with no navigation. The head is static — a single <title> tag. The head-management API useHead({...}) is used purely to set that title. Both packages are overkill, and both are mandatory because Nuxt's default assumption is that you will, at some point, want routing and dynamic head tags.
Tree-shaking can't eliminate either of these because Nuxt's own code paths reference them. You can't opt out without leaving Nuxt-land.
Paying for features you don't use
The bigger lesson isn't Nuxt-specific. Every meta-framework — Next.js, Remix, SvelteKit, Nuxt — bundles the features its design centers. These features have real value for real apps:
- File-based routing that scales to dozens of pages
- SSR/SSG/ISR mode switching per route
- Layouts and nested routes
- Head / meta / OG image management
For a 50-page blog or a 30-route dashboard, those features pay for themselves many times over. For a single-page SPA, they're fixed cost with zero benefit. The meta-framework tax is real, and it hits hardest on the smallest apps.
This isn't a Nuxt flaw. It's a "match the framework to the app shape" lesson. If the app fits in one page, pick Vue or Solid directly. If the app needs 20 pages and a backend, picking plain Vue means reinventing vue-router and head management yourself, which is far worse than the 18 kB Nuxt ships.
The code itself is nearly identical to Vue
The actual component code is basically the Vue port, just moved into Nuxt's pages/index.vue convention:
<!-- pages/index.vue -->
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import type { PortfolioData, Lang } from '~/types'
import { loadPortfolioData } from '~/data'
import { filterAndSort, type FilterState } from '~/filter'
import { MESSAGES, detectDefaultLang } from '~/i18n'
useHead({
title: 'SEN Portfolio',
})
const status = ref<'loading' | 'error' | 'ready'>('loading')
const data = ref<PortfolioData | null>(null)
const lang = ref<Lang>(detectDefaultLang())
// ...
</script>
The only Nuxt-specific line is useHead({...}). I could have used a <Title> component instead to shave off @unhead for this specific case, but at that point you're writing anti-idiomatic Nuxt, and the routing cost is still there. Once you're committed to Nuxt, you commit to its defaults.
Nitro is not in the bundle
Nuxt 3 ships with a server engine called Nitro, but it's only active when you're running SSR or API routes. In SPA + static generation mode, Nitro runs at build time only and ships nothing to the client. The 52.01 kB does not contain any Nitro code. If I'd been measuring "total footprint of a full-stack Nuxt app," I'd talk about Nitro; here, it's a build-time tool that's invisible to the browser.
The lesson
The comparison so far has shown that framework runtime cost is real and measurable. Nuxt adds a new wrinkle: meta-framework fixed cost is also real, and it doesn't shrink for small apps the way core framework runtime cost does for simpler content.
Nothing in this article is a criticism of Nuxt as a choice for apps that match its shape. I'd use Nuxt for any multi-page site. But for a one-page SPA where every kilobyte matters, Vue directly (or Solid, or Svelte) is the right call.
Byte-identical shared files
$ diff repos/portfolio-app-react/src/filter.ts repos/portfolio-app-nuxt/filter.ts
# no output
filter.ts, types.ts, data.ts, style.css, and the test file are identical across all ports.
Tests
14 Vitest cases on filter.ts, same as every other port. No Nuxt-specific test layer.
Scoreboard
| Port | gzip | vs React |
|---|---|---|
| 021 React | 49.00 kB | — |
| 022 Vue | 28.76 kB | −41% |
| 023 Svelte | 18.92 kB | −61% |
| 024 Solid | 8.33 kB | −83% |
| 025 Nuxt | 52.01 kB | +7% |
Series
This is entry #25 in my 100+ public portfolio series, and #5 in the framework comparison.
- 📦 Repo: https://github.com/sen-ltd/portfolio-app-nuxt
- 🌐 Live: https://sen.ltd/portfolio/portfolio-app-nuxt/
- 🏢 Company: https://sen.ltd/
Next up: SvelteKit (026). Plain Svelte was 18.92 kB; SvelteKit adds +72% for the same reasons Nuxt adds +81% to Vue — meta-framework tax applies to every ecosystem.

Top comments (0)