Next.js vs. Vue: The Reactivity Primitive You Don't Need
Vue's Composition API needs ref/computed for every stateful value, server or client. This storefront's 13 dependencies carry zero state library — here's why.
Next.js and Vue answer a different question first. Next.js asks "does this component need to be interactive at all" and defaults to no — a Server Component ships zero client JavaScript unless it opts in. Vue asks "how do I make this value reactive" for every stateful piece, client or server, because reactivity is Vue's whole model. On this storefront that difference shows up as a number: 13 runtime dependencies, and not one of them is a state-management or data-fetching library.
The decision in one table
| Next.js App Router | Vue 3 | |
|---|---|---|
| Default rendering mode | Static, per route, inferred from what the route touches | Client-reactive by default; SSR/SSG needs Nuxt |
| Stateful value | useState, scoped to one Client Component | ref()/reactive(), explicit .value access, works the same on server or client |
| Derived value | A plain expression, recomputed on render | computed(), a distinct primitive with its own caching semantics |
| Component format | .tsx — JSX compiled by the same pipeline as everything else | .vue Single-File Component — template, script and style in one file, its own compiler step |
| Reaching the server for data | async Server Component, no client roundtrip, no store | Composable + fetch/useFetch (Nuxt) or a manual store (Pinia) |
| Global client state | Not needed anywhere in this codebase — zero Redux/Zustand/Pinia-equivalent dependency | Commonly Pinia once state crosses more than one component tree |
Vue's reactivity system is genuinely good at what it does — ref() and computed() give you fine-grained, automatic dependency tracking with none of React's manual-memoization tax. That's a real advantage for a component that's reactive by nature: a live filter count, a form that revalidates on every keystroke. The tradeoff is that the primitive is required everywhere state exists, including inside a .vue file that never leaves the server, because Vue doesn't have a component type that skips reactivity the way a Server Component skips the client runtime entirely.
What that looks like in a real file
This storefront's /templates filter grid is the one place in the codebase that holds meaningfully interactive client state — a set of chip filters over 111 products — and it's a single useState call, no companion primitive:
// src/components/organisms/TemplatesExplorer.tsx
"use client";
export default function TemplatesExplorer() {
const [filters, setFilters] = useState<TemplateFilters>({});
const results = filterTemplates(catalog, filters);
// ...
}
results is not memoized, not wrapped in anything, not declared with a second primitive for "this depends on filters" — it's a plain function call that re-runs on every render, because filterTemplates over 111 in-memory objects costs nothing worth caching. The Composition API equivalent needs two primitives working together where this needs one:
// The Vue 3 Composition API shape of the same component
const filters = ref<TemplateFilters>({});
const results = computed(() => filterTemplates(catalog, filters.value));
That's not a knock on Vue's code — it's four lines either way, and computed()'s caching is arguably the more correct default for anything that isn't as cheap as filtering an array in memory. The actual gap is upstream of this component: every other section of /templates — the header, the category copy, the whole page shell — is a Server Component that ships no JavaScript and needs neither useState nor ref(). This codebase's Client Component census, measured for the Svelte comparison, puts 28 of 33 organisms at zero client JS. A .vue file has no equivalent tier to render into without stepping outside Vue itself into Nuxt's server components, which are a different, newer, more restricted primitive (no client interactivity at all, not "interactivity you opted out of").
The Single-File Component tradeoff
Vue's .vue file bundles <template>, <script setup> and <style scoped> in one file, compiled by its own toolchain (@vitejs/plugin-vue or the Nuxt equivalent) before anything reaches the browser. That's a genuine authoring convenience — a component's markup, logic and scoped styles are three sections of one file instead of a .tsx file plus whatever styling mechanism the project has chosen. This codebase's .tsx files carry no separate style block at all; Tailwind's utility classes live inline in the JSX, so there's no third section to colocate and no separate compiler stage between the file you write and the JSX the framework consumes — one less toolchain step, at the cost of losing the template/logic/style separation .vue files offer for a team that prefers it explicit.
Where Vue is straightforwardly the better answer
To be direct about the side of this that doesn't favor Next.js: Vue's learning curve is real and shorter. <script setup> reads close to plain JavaScript, template syntax is closer to HTML than JSX is, and a team that doesn't already know React pays no tax learning React's rendering model, hook rules, or the Server/Client Component split this whole post is about. Vue's official ecosystem is also more centralized — routing (vue-router), state (pinia) and meta-framework (nuxt) are all maintained by the same core team with matched release cadences, where the React ecosystem's equivalent pieces are chosen independently. If the project is small, the team is Vue-native, and nothing about the routing needs Next's per-route static/dynamic inference, Vue with Nuxt is not a worse choice — it's a different one.
The build this comparison is measured against
A fresh production build of this site prerenders 352 routes with 8 dynamic route patterns (/api/download, /api/webhooks/lemonsqueezy, /auth/callback, /dashboard, /dashboard/settings, /login, /reset-password, /signup — the account and commerce surface, the only part of the app that has to run per request):
$ node -e "const m=require('./.next/prerender-manifest.json');
console.log(Object.keys(m.routes).length, 'prerendered routes');"
352 prerendered routes
None of that static output required a reactivity primitive to produce — generateStaticParams runs once at build time over the catalog array, and the resulting pages carry no runtime that needs to track a dependency graph at all. Vue's own SSG story (via Nuxt's nitro prerender) can reach a similar static count, but every prerendered .vue page is still built from components written in the reactive primitive, because that's the only component type Vue has.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Assuming "Vue is a frontend framework, Next.js is a meta-framework" is the whole comparison | Misses that Nuxt is Vue's meta-framework, and the fair comparison is Next.js vs. Nuxt for routing/SSR | Compare Next.js to Nuxt when routing is the question, Next.js to Vue-the-library when component authoring is |
Reaching for ref()/computed() inside a component that never needs to re-render | Reactivity overhead paid for content that's static after mount | If nothing changes after first render, it doesn't need a reactive primitive — server-render it |
| Treating bundle-size comparisons as settled by "Vue is smaller" | Ignores that a Server Component ships zero bytes for its own logic, smaller than any client-rendered Vue component | Count what actually reaches the client, not the runtime's base size |
| Porting a Pinia store 1:1 into a Next.js app | Recreates global client state this codebase has never needed | Check whether a Server Component can just read the data directly first |
Frequently asked questions
Is this really "Next.js vs. Vue" or "Next.js vs. Nuxt"? Both, depending on what you're deciding. For component authoring — how you write a piece of UI and its state — it's Next.js's React (with Server Components) vs. Vue's Composition API, which is the comparison this post makes. For routing, SSR and static generation, the fair opponent to Next.js is Nuxt, since plain Vue has no built-in server-rendering story.
Does Vue have anything like a Server Component? Nuxt server components exist, but they render once on the server with no client-side interactivity at all — closer to a static include than a component you can later hydrate selectively. React's Server/Client Component split lets a single page tree mix both, decided per component, which is the mechanism behind this codebase's 352 static routes each still carrying interactive pieces where they're needed.
Which one has better TypeScript support?
Both are strong today. Vue's <script setup lang="ts"> template type-checking has closed most of the gap that used to exist against JSX, where types flow through the same .tsx file the component is written in. Neither is a deciding factor anymore the way it was a few versions ago.
Do I need Pinia if I pick Vue? Only once state needs to cross component trees that don't share a parent — the same threshold that would push a React app toward Context or a state library. This codebase has never crossed it: zero state-management dependency across 13 runtime packages, because the pieces that need to talk to each other are colocated in one Server Component tree that reads the catalog directly.
Templates where this ships
ASoc Fade is a barbershop booking site built on exactly the static-by-default model this post describes. ASoc Fiscal is a financial-platform landing page with the same zero-client-state architecture. ASoc Flow automates workflow marketing copy on the identical foundation — all three prerender in full, with interactivity added only where a section actually needs it.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the Server/Client Component split this post leans on, read the React vs. Svelte census; for the static-rendering triggers behind the 352-route count, read static rendering in the App Router.
