Porting the Same Portfolio Landing to Vue 3 — 41% Smaller Gzip Than React
Same spec, same data, same CSS as my React landing page, but ported to Vue 3 + Composition API. The ground rule for the series: the component layer is the only thing allowed to change. Shared code stays byte-identical. Result: gzip drops from 49.00 kB to 28.76 kB.
This is entry #2 in my framework comparison series. Rules:
-
types.ts,filter.ts,data.ts,style.css, andtests/filter.test.tsmust be byte-identical across all ports - Only the component layer changes
- All features must exist in every port — no "I simplified it"
Vue 3 delivered a gzip of 28.76 kB against React's 49.00 kB. A 41% reduction from swapping nothing but the component framework. Here's what the port actually looks like, and why the number isn't surprising once you trace where the bytes go.
🔗 Live demo: https://sen.ltd/portfolio/portfolio-app-vue/
📦 GitHub: https://github.com/sen-ltd/portfolio-app-vue
Feature parity with the React version: filter, search, sort, URL query sync, JA/EN, dark UI, responsive. Vue 3 + TypeScript + Vite + Composition API.
<script setup> is remarkably flat
Composition API with <script setup> gives you a genuinely thin component syntax:
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import type { PortfolioData, Entry, Lang } from './types'
import { loadPortfolioData } from './data'
import { filterAndSort, type FilterState, type SortKey } from './filter'
import { MESSAGES, detectDefaultLang } from './i18n'
const status = ref<'loading' | 'error' | 'ready'>('loading')
const errorMsg = ref('')
const data = ref<PortfolioData | null>(null)
const lang = ref<Lang>(detectDefaultLang())
const filter = ref<FilterState>({ query: '', category: 'all', stack: 'all', stage: 'all', sort: 'number' })
loadPortfolioData()
.then((d) => { data.value = d; status.value = 'ready' })
.catch((e) => { errorMsg.value = String(e); status.value = 'error' })
const visible = computed(() =>
data.value ? filterAndSort(data.value.entries, filter.value, lang.value) : []
)
</script>
In React, the equivalent is five useState calls, a useEffect for loading, and a useMemo for the filtered list — plus the dependency-array bookkeeping that comes with them. In Vue, computed tracks its dependencies automatically. There's no dep array to forget, no stale closure to debug.
v-model + watchEffect for the URL sync
Two-way binding happens declaratively:
<script setup lang="ts">
watchEffect(() => {
const q = new URLSearchParams()
if (filter.value.query) q.set('q', filter.value.query)
if (filter.value.category !== 'all') q.set('category', filter.value.category)
q.set('lang', lang.value)
window.history.replaceState(null, '', `${window.location.pathname}?${q.toString()}`)
})
</script>
<template>
<input type="text" v-model="filter.query" :placeholder="m.searchPlaceholder" />
<select v-model="filter.category">
<option value="all">{{ m.allLabel }}</option>
<option v-for="c in data.categories" :key="c.id" :value="c.id">
{{ c.name[lang] }}
</option>
</select>
</template>
v-model eliminates the value + onChange double-declaration every React form field needs. watchEffect auto-detects its dependencies, so you physically cannot forget to include one. Two classes of bug — missing dep arrays and handler/value mismatches — go away.
Why the bytes differ
Vite's build output tells the story:
dist/assets/index-<hash>.js 104.94 kB │ gzip: 28.76 kB
Versus React's 49.00 kB. Where did 20+ kB go?
- react-dom is heavy. The virtual DOM reconciler, fiber scheduler, and event system all live there. It's roughly 40 kB before your app code starts.
-
Vue's reactivity is proxy-based and compile-time-optimized. When
filter.querychanges, Vue knows — at compile time — exactly which DOM updates it affects. React's default mental model is "re-run the component function; diff the result." - Vue's templates compile to render functions. The compiled output is denser than JSX-to-React-element because it can inline hoisted static parts and skip diffing branches that provably can't change.
For an app that does what this one does — small tree, frequent filter changes, read-only data — Vue's model aligns better with what's actually needed, and the bundle reflects that.
Component split uses .vue files
<!-- EntryCard.vue -->
<script setup lang="ts">
import type { Entry, Lang } from './types'
import { MESSAGES } from './i18n'
const props = defineProps<{
entry: Entry
lang: Lang
stackMap: Map<string, { id: string; name: string; color: string }>
stageMap: Map<string, { id: string; icon: string; name: Record<Lang, string> }>
categoryMap: Map<string, { id: string; name: Record<Lang, string> }>
}>()
</script>
<template>
<article class="card">
<div class="card-head">
<span class="entry-number">#{{ String(entry.number).padStart(3, '0') }}</span>
<span v-if="stage" class="stage-badge">{{ stage.icon }} {{ stage.name[lang] }}</span>
<span v-if="entry.source === 'closed'" class="source-badge">🔒 Closed source</span>
</div>
<!-- ... -->
</article>
</template>
defineProps<T>() is fully type-inferred, so TypeScript experience is comparable to React's functional components. Single File Components keep template, script, and optional scoped styles colocated in one .vue file without any ambient imports.
Byte-identical shared code
The shared files verifiably don't change:
$ diff repos/portfolio-app-react/src/filter.ts repos/portfolio-app-vue/src/filter.ts
# no output — byte-identical
Same for types.ts, data.ts, style.css, and tests/filter.test.ts. This is the constraint that makes the bundle comparison meaningful — what changes between ports is strictly the framework's rendering layer, so the bundle delta is a clean measurement of framework overhead for this workload.
Tests
14 Vitest cases, run identically on every port in the series:
test('filters by category', () => {
const r = filterAndSort(entries, { ...defaults, category: 'dev-tool' }, 'en')
assert.ok(r.every((e) => e.category === 'dev-tool'))
})
No Vue in the test setup. Logic lives in pure functions; swapping framework doesn't change what passes or fails.
Series
This is entry #22 in my 100+ public portfolio series, and entry #2 in the framework comparison series.
- 📦 Repo: https://github.com/sen-ltd/portfolio-app-vue
- 🌐 Live: https://sen.ltd/portfolio/portfolio-app-vue/
- 🏢 Company: https://sen.ltd/
Next up: Svelte 5 + Runes at 18.92 kB (another −23%).

Top comments (0)