All lessons
FrontendIntermediate14 min readWorth knowing

Vue 3 and Nuxt

Reactive components with the Composition API, Pinia stores and full-stack Nuxt apps with SSR.

  • Vue 3
  • Composition API
  • Pinia
  • Nuxt
  • SSR
Step 1/5
CSR: the server sends an almost empty HTML shell and a big JavaScript bundle. The browser shows a spinner and gets to work.

In 30 seconds

Vue is a JavaScript framework for building user interfaces from components, known for a gentle learning curve and a precise reactivity system. Vue 3's Composition API organises logic into reusable functions, and Pinia is its official state store. Nuxt is the full-stack framework on top of Vue, playing the same role Next.js plays for React: routing by files, server-side rendering, data fetching and server API routes in one project.

Not in my daily stack — I know the concepts and would learn the details when a project needs it.

Why it matters

  • Vue is one of the most popular frontend frameworks, especially strong in Europe and Asia, and many existing products and agencies are built on it.
  • Its fine-grained reactivity tracks exactly which data a component uses, so updates are efficient without manual memoisation.
  • Single-file components keep template, logic and styles together, which many teams find easy to read and onboard into.
  • Nuxt gives Vue teams SSR, SEO-friendly pages, server routes and hybrid rendering with strong conventions, comparable to what Next.js gives React teams.

How it works

01The idea: reactive data drives the view

In Vue you declare state, and the template describes what the UI should look like for that state. When state changes, Vue updates only the parts of the DOM that depend on it. Under the hood Vue 3 wraps your data in JavaScript Proxies; when a component renders, Vue records which reactive properties it read, and when one of them changes, it re-runs just that component's render. Think of a spreadsheet: cells with formulas recalculate automatically when the cells they reference change. A Vue app is a tree of components, usually written as Single-File Components (.vue files) with a template, a script and optional scoped styles. Vue 3.5 is the current stable line; Vue 3.6 with the new compiler-based 'Vapor mode' is in release-candidate stage as of late 2026.

02A component with the Composition API

Inside script setup, ref() creates a reactive value (read and write .value in script, used directly in the template), reactive() makes a reactive object, computed() derives cached values, and watch() runs side effects when sources change. Props are declared with defineProps and, since Vue 3.5, can be destructured while staying reactive. Events go up with defineEmits, and defineModel creates two-way binding for v-model. Templates use directives: v-if, v-for (with a :key), v-bind (shorthand :) and v-on (shorthand @).

vue
<script setup lang="ts">
import { ref, computed } from "vue";
 
const { items = [] } = defineProps<{ items?: { id: number; name: string; price: number }[] }>();
const emit = defineEmits<{ select: [id: number] }>();
 
const query = ref("");
const filtered = computed(() =>
  items.filter((i) => i.name.toLowerCase().includes(query.value.toLowerCase()))
);
</script>
 
<template>
  <input v-model="query" placeholder="Search products" />
  <p v-if="!filtered.length">No results</p>
  <ul v-else>
    <li v-for="item in filtered" :key="item.id" @click="emit('select', item.id)">
      {{ item.name }} — {{ item.price.toFixed(2) }}
    </li>
  </ul>
</template>

03Composables: reusable logic

A composable is a plain function, named useSomething, that uses Vue's reactivity APIs and returns reactive state. It is the Vue equivalent of a React custom hook, but it runs once per component setup instead of on every render, so there are no dependency arrays or stale closure traps. Composables replace mixins from Vue 2, which had naming collisions and unclear origins. Use them for data fetching, form logic, browser APIs (online status, media queries) and shared behaviour. The VueUse library offers hundreds of ready-made composables. Lifecycle hooks such as onMounted and onUnmounted can be called inside composables to set up and clean up listeners.

ts
// composables/useOnline.ts
import { ref, onMounted, onUnmounted } from "vue";
 
export function useOnline() {
  const online = ref(typeof navigator === "undefined" ? true : navigator.onLine);
  const update = () => (online.value = navigator.onLine);
  onMounted(() => {
    window.addEventListener("online", update);
    window.addEventListener("offline", update);
  });
  onUnmounted(() => {
    window.removeEventListener("online", update);
    window.removeEventListener("offline", update);
  });
  return { online };
}

04State management with Pinia

For state shared across many components, such as the logged-in user or a cart, Vue's official store is Pinia (it replaced Vuex). A store is defined with defineStore and can use the same setup syntax as components: refs are state, computed are getters, functions are actions. Pinia is type-safe, modular (one store per domain), supports Vue DevTools and works with SSR in Nuxt. Use storeToRefs when destructuring state so values stay reactive. Not everything belongs in a store: server data is often better kept in a data-fetching layer such as Nuxt's useFetch or TanStack Query for Vue, and local UI state stays in components.

ts
// stores/cart.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
 
export const useCartStore = defineStore("cart", () => {
  const items = ref<{ id: number; qty: number; price: number }[]>([]);
  const total = computed(() => items.value.reduce((s, i) => s + i.qty * i.price, 0));
 
  function add(id: number, price: number) {
    const line = items.value.find((i) => i.id === id);
    line ? line.qty++ : items.value.push({ id, qty: 1, price });
  }
  return { items, total, add };
});

05Nuxt: the full-stack Vue framework

Nuxt 4 is the current major version (Nuxt 3 reached end of life in mid-2026). It gives you file-based routing from app/pages, layouts, auto-imported components and composables, server-side rendering by default, and a server directory powered by Nitro for API routes and middleware. Nitro can deploy the same app to Node.js servers, serverless platforms or edge runtimes through presets. Nuxt 4 moved application code into an app/ directory by default, separating it from server/, and improved data-fetching behaviour and TypeScript project separation. Modules (@nuxt/image, @nuxt/content, @pinia/nuxt, @nuxtjs/i18n) add features with one line of config.

bash
npx nuxi@latest init my-shop
cd my-shop && npm run dev
 
# Project layout (Nuxt 4)
# app/pages/index.vue           -> /
# app/pages/products/[id].vue   -> /products/:id
# app/components/ProductCard.vue (auto-imported)
# app/composables/useCart.ts     (auto-imported)
# server/api/products/[id].get.ts -> GET /api/products/:id
# nuxt.config.ts

06Data fetching and server routes in Nuxt

In pages and components, use useFetch or useAsyncData. They run on the server during SSR, serialise the result into the HTML payload, and reuse it on the client during hydration, so data is not fetched twice. They return reactive data, status, error and a refresh function, and accept a key, options like lazy and server, and reactive parameters that re-fetch when they change. For event handlers like form submissions, use $fetch directly. Server routes live in server/api and export defineEventHandler; they run only on the server, so they can read secrets from runtimeConfig and talk to databases.

ts
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id");
  const product = await findProduct(id); // your DB call
  if (!product) throw createError({ statusCode: 404, statusMessage: "Not found" });
  return product;
});
 
<!-- app/pages/products/[id].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: product, status, error } = await useFetch(`/api/products/${route.params.id}`);
useSeoMeta({ title: () => product.value?.name ?? "Product" });
</script>

07Rendering modes and deployment

Nuxt supports universal rendering (SSR plus hydration), client-only SPA mode, static generation and hybrid rendering where each route gets its own rule. Route rules in nuxt.config let you prerender marketing pages at build time, cache product pages with stale-while-revalidate, render a dashboard client-side only, and add redirects or headers, all in one app. Build with nuxi build for a server or serverless target, or nuxi generate for a static site. The output in .output is self-contained: on a VPS you run node .output/server/index.mjs behind nginx or a process manager; on Vercel, Netlify or Cloudflare the preset is detected automatically.

ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@pinia/nuxt", "@nuxt/image"],
  runtimeConfig: {
    apiSecret: "",                       // NUXT_API_SECRET, server only
    public: { apiBase: "/api" },         // NUXT_PUBLIC_API_BASE, exposed to client
  },
  routeRules: {
    "/": { prerender: true },
    "/products/**": { swr: 600 },
    "/dashboard/**": { ssr: false },
    "/old-shop/**": { redirect: "/products/**" },
  },
});

Use it when

  • Teams that like HTML-like templates and a gentle learning curve, or that already have Vue experience.
  • Content sites, e-commerce and marketing pages that need SSR or prerendering for SEO, built with Nuxt.
  • Progressively enhancing server-rendered pages (Laravel, Django) with interactive widgets, where Vue drops in easily.
  • Admin panels and dashboards where Vue's ecosystem (Vuetify, PrimeVue, Element Plus, Nuxt UI) speeds up delivery.

Skip it when

  • When the team and component libraries are already React-based; mixing ecosystems doubles maintenance.
  • React Native code sharing for mobile is a hard requirement; Vue's native options are much less mature.
  • Very small static pages where plain HTML or a lightweight tool like Astro is enough.

Alternatives

  • React + Next.js

    Largest ecosystem and hiring pool; Server Components and a JSX-based model.

  • Svelte + SvelteKit

    Compiler-first, very small bundles, runes-based reactivity.

  • Angular

    Batteries-included enterprise framework with signals, DI and strict structure.

  • Astro

    Content-first sites that ship little JavaScript; can host Vue components as islands.

Best practices

  1. Use script setup with TypeScript and the Composition API for all new code.
  2. Always give v-for items a stable, unique :key, and never combine v-if and v-for on the same element.
  3. Extract reusable logic into composables, and keep components focused on rendering.
  4. In Nuxt, fetch page data with useFetch or useAsyncData, not $fetch inside setup, to avoid double fetching.
  5. Keep secrets in server-only runtimeConfig and expose only public keys under runtimeConfig.public.
  6. Use shallowRef for large immutable data and computed for derived values instead of watchers that copy state.
  7. Lint with ESLint plus eslint-plugin-vue and type-check templates with vue-tsc in CI.

Common mistakes

  • Destructuring a reactive() object or a Pinia store without toRefs or storeToRefs, which silently breaks reactivity.
  • Using watch to keep two pieces of state in sync instead of a computed value.
  • Accessing window or localStorage during SSR setup, causing server errors or hydration mismatches.
  • Calling $fetch at the top level of a Nuxt page, which fetches once on the server and again on the client.
  • Putting all application state in Pinia, including server cache and transient UI state.

Deep dive

08Inside the reactivity system

Each reactive object is a Proxy whose get trap calls track(target, key) and whose set trap calls trigger(target, key). Effects, such as a component's render function, a computed or a watcher, run inside an active-effect context; every tracked read during that run registers the effect as a subscriber of that key. On trigger, Vue schedules subscribed effects; component updates are batched and flushed asynchronously in a microtask, which is why the DOM is updated after nextTick(). Vue 3.4 made computed values lazy and only notifying when their result actually changes, and Vue 3.5 refactored the internals to a version-counting, doubly-linked-list design that cut memory use significantly. Use markRaw for objects that should never be reactive, such as map or chart library instances.

ts
import { ref, computed, nextTick, markRaw, shallowRef } from "vue";
 
const count = ref(0);
const isEven = computed(() => count.value % 2 === 0); // only notifies on true<->false
 
count.value++;          // DOM not updated yet
await nextTick();       // now it is
 
// Large third-party instance: keep it out of reactivity
const map = shallowRef(markRaw(createMap("#map")));

09Compiler optimisations and Vapor mode

Vue's template compiler is why Vue's virtual DOM is cheaper than a naive one. It hoists static nodes so they are created once, marks dynamic nodes with patch flags (only text changes, only class changes), and groups dynamic children into blocks so the diff skips static structure entirely. This is possible because templates are statically analysable, unlike arbitrary JSX. Vapor mode, arriving in Vue 3.6, goes further: a component opted in with script setup vapor compiles to direct DOM operations bound to reactive effects, with no virtual DOM at all, similar to Solid or Svelte. It can interoperate with regular components, so teams can adopt it per component on performance-critical screens once it is stable.

10SSR internals in Nuxt: payload, keys and caching

During SSR, Nuxt runs your components on the server, and every useAsyncData or useFetch result is stored under its key in the payload, serialised into the HTML. On hydration, the client reads data from the payload instead of refetching. Keys matter: two calls with the same key share data, and an auto-generated key from file and line works only when the call is at top level of setup. Fetching inside a conditional or a loop needs explicit keys. Nitro's routeRules with swr or isr cache rendered HTML at the server or CDN layer, and defineCachedEventHandler caches API responses. Remember that server state is shared across requests: never store per-user data in module-level variables in server code or composables.

ts
// Explicit key + transform to keep the payload small
const { data } = await useAsyncData(
  `product-${id}`,
  () => $fetch(`/api/products/${id}`),
  { pick: ["id", "name", "price"] }
);
 
// server/api/rates.get.ts — cached for 10 minutes
export default defineCachedEventHandler(async () => {
  return await $fetch("https://api.example.com/rates");
}, { maxAge: 600 });

11Performance and scaling a large Vue codebase

For bundle size, use defineAsyncComponent and route-level lazy loading so each page loads only what it needs; Nuxt does route splitting automatically, and the Lazy prefix (LazyChart) defers a component until it renders. Nuxt also supports lazy hydration strategies such as hydrate-on-visible, so below-the-fold interactive components do not cost main-thread time at load. For long lists, use virtual scrolling (vue-virtual-scroller or TanStack Virtual) and v-memo for expensive rows. In large codebases, Nuxt layers let several apps share components, config and composables from a base layer, and strict TypeScript with vue-tsc catches template type errors. Profile with Vue DevTools' performance timeline before optimising.

Real-world scenarios

01

SEO problems on a Vue single-page shop

Problem

An online shop built as a client-only Vue SPA ranks poorly. Product pages show an empty shell to crawlers and social previews have no titles or images.

How to solve it

Migrate to Nuxt, reusing the existing components and Pinia stores. Move routes into app/pages, replace client fetching with useFetch so product data renders on the server, and add useSeoMeta for titles, descriptions and Open Graph images per product. Use routeRules to prerender category pages and apply swr caching to product pages so the server is not hit on every view. Add a sitemap module and canonical URLs. Verify with view-source and a rich results test that content is in the initial HTML.

02

A dashboard freezes when filtering 10,000 rows

Problem

An admin table built with Vue lags for seconds when the user types in a filter box. Profiling shows heavy reactivity and rendering work on every keystroke.

How to solve it

Store the raw dataset in a shallowRef so Vue does not deeply proxy 10,000 objects. Debounce the search input with VueUse's refDebounced, and compute filtered results with computed so they recalculate only when inputs change. Render with a virtual scroller so only visible rows exist in the DOM. If rows are heavy components, use v-memo keyed on the row's data. For server-scale data, move filtering and pagination to the API instead. Re-profile in DevTools to confirm the main thread stays responsive.

03

User data leaking between visitors after SSR

Problem

After enabling SSR, a user briefly saw another user's name in the header. The app stores the current user in a composable with a module-level ref.

How to solve it

Module-level state on the server is shared across all requests, so one request's user leaks into another's render. Move the user into a Pinia store or Nuxt's useState, both of which create state per request on the server and hydrate it on the client. Load it in a server-side plugin or route middleware from the session cookie, and never cache personalised pages with swr or CDN rules. Audit other composables for module-level refs, and add a test that renders two concurrent requests with different users.

Cheat sheet

New Vue app (Vite)
npm create vue@latest
New Nuxt app
npx nuxi@latest init my-app
Two-way bound prop
const model = defineModel<string>()
Template ref (3.5+)
const input = useTemplateRef<HTMLInputElement>("input")
Keep store values reactive
const { items, total } = storeToRefs(useCartStore())
Nuxt SSR data
const { data, status, refresh } = await useFetch("/api/items")
Client-only widget
<ClientOnly><MapView /></ClientOnly>
Per-route caching
routeRules: { "/blog/**": { swr: 3600 } }
Type-check templates
npx vue-tsc --noEmit
Build Nuxt for Node
npx nuxi build && node .output/server/index.mjs

Questions & answers

Q1How does Vue's reactivity differ from React's model?

React re-runs a component function when its state or props change and compares the resulting virtual DOM; you control unnecessary work with memoisation and dependency arrays. Vue tracks dependencies automatically: reactive objects are Proxies, and during render Vue records which properties were read. When one changes, only components that used it re-render. Setup code runs once, not on every render, so there are no stale closures. The result is that Vue apps rarely need manual memoisation. React's model is more explicit and functional; Vue's is more automatic and closer to signals, which other frameworks have since adopted.

Q2ref or reactive: which should I use?

The Vue team recommends ref as the primary API. ref works with any value type, including primitives, and you can replace the whole value or pass it around without losing reactivity. reactive only works with objects and loses reactivity if you destructure it or reassign the variable, which is a common source of bugs. In templates refs are unwrapped automatically, so the .value cost appears only in script. I use reactive occasionally for a local form object that is never reassigned, and ref for everything else, plus shallowRef for large data that is replaced rather than mutated.

Q3What does Nuxt add on top of Vue?

Vue is the component library; Nuxt is the application framework. Nuxt adds file-based routing and layouts, server-side rendering with hydration, prerendering and per-route caching rules, data-fetching composables that transfer server data to the client, a Nitro server for API routes and middleware, SEO helpers, auto-imports, runtime configuration, and a module ecosystem for images, content, i18n and auth. It also deploys the same project to Node.js, serverless or edge platforms via presets. It is comparable to Next.js for React: you could assemble these pieces yourself with Vite and Vue Router, but Nuxt makes them consistent.

Q4What is a hydration mismatch and how do you avoid it?

With SSR, the server renders HTML and the client then hydrates it, attaching Vue's reactivity to existing DOM. If the client's first render produces different markup from the server's, Vue warns about a mismatch and may re-render parts of the page, causing flicker or bugs. Typical causes are reading window, localStorage or the current time during setup, random IDs, and locale-dependent formatting. Fixes: move browser-only code into onMounted, wrap browser-only widgets in ClientOnly, use useId for stable IDs, and pass time or locale from the server through useState or the payload.

Q5Pinia or composables for shared state?

A composable that declares its refs inside the function creates new state per component; declaring refs at module level shares state, but on the server that state leaks between users' requests during SSR, which is a security problem. Pinia solves this: stores are created per app instance, so SSR requests are isolated, and you get DevTools support, plugins and hydration of store state. My rule: local reusable logic in composables, global client state in Pinia stores, and server data in useFetch or a query library. In Nuxt, useState is the lightweight SSR-safe option for small shared values.

Q6How would you migrate a large Vue 2 app to Vue 3?

Vue 2 reached end of life at the end of 2023, so migration is a security matter. I would first upgrade to Vue 2.7, which backports the Composition API and script setup, and start writing new code that way. Then I would replace libraries that do not support Vue 3: Vuex to Pinia, the old router to Vue Router 4, and UI kits to their Vue 3 versions. The migration build (@vue/compat) can run the app on Vue 3 with Vue 2 behaviour and warnings, so you fix deprecations incrementally. Moving from Webpack to Vite usually comes at the same time. Good end-to-end tests are what make this safe.

Senior-level questions

S1How would you structure a large Nuxt application shared by several teams?

I would use Nuxt layers: a base layer with the design system, shared composables, auth and API client, and feature or product layers that extend it. Each team owns its layer in a monorepo managed with pnpm workspaces and Turborepo or Nx, with strict boundaries enforced by lint rules so features do not import each other's internals. Data access goes through typed API clients generated from OpenAPI, not ad-hoc $fetch calls. Shared state is minimal and lives in clearly owned Pinia stores. CI runs vue-tsc, unit tests with Vitest and end-to-end tests with Playwright per affected layer. This keeps one deployable app with clear ownership, which is usually better than jumping straight to micro-frontends.

S2Compare Nuxt and Next.js for a new product. What would decide it?

Technically they are close: both offer file routing, SSR, static and hybrid rendering, server routes and edge deployment. The decision is mostly about people and ecosystem. Next.js has the larger hiring pool, React Server Components for fine-grained server rendering, and React Native for code sharing with mobile. Nuxt offers Vue's simpler reactivity, less boilerplate through auto-imports and conventions, Nitro's portability across hosts without vendor-specific features, and a strong module ecosystem. If the team knows React or needs mobile code sharing, I pick Next.js. If the team is Vue-based, or the product is content and SEO heavy with a small team that values conventions, Nuxt is an excellent choice. Rewriting to switch frameworks rarely pays off.

S3How do you handle authentication securely in Nuxt with SSR?

I keep the session in an HttpOnly, Secure, SameSite=Lax cookie set by the server, never a token in localStorage, so it is available during SSR and not readable by scripts. Server routes and server middleware validate the session on every request; client route middleware only improves UX by redirecting early, since it can be bypassed. During SSR, forward the incoming cookie when calling your own API, using useRequestFetch or useRequestHeaders. User state goes into useState or Pinia so it is per request. Personalised pages are excluded from swr and CDN caching. For third-party login, nuxt-auth-utils or a provider SDK handles OAuth flows, and CSRF protection is required for cookie-authenticated mutating requests.

Glossary

Single-File Component
A .vue file combining a component's template, script and styles in one place.
Composition API
Vue 3's function-based way to write component logic with ref, computed, watch and lifecycle hooks.
ref
A reactive container for any value; read and write it through .value in script.
Composable
A reusable function using Vue's reactivity APIs, conventionally named useSomething.
Pinia
Vue's official state management library, with type-safe, modular stores and SSR support.
Hydration
The client attaching interactivity to HTML that the server already rendered.
Nitro
Nuxt's server engine that runs API routes and deploys to Node, serverless or edge platforms.
Route rules
Nuxt config mapping URL patterns to rendering, caching, redirect and header behaviour.
Vapor mode
An upcoming Vue compilation mode that updates the DOM directly without a virtual DOM.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.