frontend / vue / 11_nuxt.md

Nuxt — SSR, SSG, ISR, Server Routes

6 min read source

Nuxt — SSR, SSG, ISR, Server Routes

TL;DR

Nuxt 3 is to Vue what Next.js is to React — the full-stack meta-framework. It gives you file-based routing, multiple rendering modes (SSR, SSG, ISR, SPA, hybrid per-route), server routes (server/api/), auto-imports, and a rich module ecosystem. Senior topics: when to pick each rendering mode, how data fetching interacts with hydration, useFetch/useAsyncData vs $fetch, server-only vs universal code, and the App Router-equivalent layers + components subdirectories that scale across teams.

Interview Q&A

Q: Rendering modes — what’s the trade-off?

A: Set per-route or app-wide via nitro.routeRules or defineNuxtConfig({ routeRules }):

Mode When
SSR (universal) dynamic per-request HTML; fresh data, slower TTFB than static
SSG (prerendered) static HTML built at build time; fastest, requires re-build for changes
ISR (incremental) static + revalidate every N seconds; “static-ish” with freshness
SWR serve stale-while-revalidate at the edge
SPA (client-only) no SSR; HTML is a shell, JS renders everything; admin panels behind auth
Hybrid per-route mix; marketing pages SSG, dashboard SPA, blog posts ISR
// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    "/": { prerender: true },                                 // SSG
    "/blog/**": { isr: 60 },                                  // ISR — revalidate every 60s
    "/dashboard/**": { ssr: false },                          // SPA
    "/api/**": { cors: true, headers: { "x-frame": "deny" } },
  },
});

The decision flow: default to SSR for most pages, SSG for marketing/landing, SPA for authed admin where SEO doesn’t matter, ISR for content that changes occasionally.

Q: useFetch vs useAsyncData vs $fetch — when each?

A:

Where it runs Reactive? Use for
useFetch(url) both SSR and client yes — { data, pending, error, refresh } most data fetching; convenient wrapper around useAsyncData + $fetch
useAsyncData(key, fn) both SSR and client yes when fetching needs custom logic (multi-source, computed body)
$fetch(url) wherever called (raw) no — returns a Promise event handlers, mutations, server routes
<script setup>
// Page-level fetch — SSR-aware, no waterfall
const { data: post, pending, error, refresh } = await useFetch(`/api/posts/${id}`);

// Custom logic with useAsyncData
const { data: feed } = await useAsyncData("feed", async () => {
  const [posts, ads] = await Promise.all([
    $fetch("/api/posts"),
    $fetch("/api/ads"),
  ]);
  return { posts, ads };
});

// Inside a handler — just $fetch
async function submit() {
  await $fetch("/api/posts", { method: "POST", body: form.value });
  await refresh();   // re-run the useFetch above
}
</script>

Key: useFetch/useAsyncData are SSR-aware — fetched on the server, serialized into the HTML, rehydrated on the client (no double-fetch). $fetch is raw — use for handlers and inside server routes.

Q: Server routes (server/api/).

A: Nuxt 3 ships with Nitro, a server engine. Files under server/api/ become endpoints:

// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id");
  const user = await db.user.findUnique({ where: { id: Number(id) } });
  if (!user) throw createError({ statusCode: 404 });
  return user;
});

// server/api/users.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  return db.user.create({ data: body });
});

These run on the server only; you can use Node APIs, DB clients, etc. The client’s $fetch("/api/users/1") hits them. Routing follows Nitro’s conventions ([id] for params, method-suffix .get.ts/.post.ts).

Q: Auto-imports — what gets imported automatically?

A: Nuxt auto-imports:

  • All Vue Composition API (ref, computed, watch, onMounted, etc.).
  • Nuxt composables (useFetch, useRoute, useRouter, useState, useCookie, useRuntimeConfig).
  • Components in components/ (the file name = the component name).
  • Composables in composables/ (the function name).
  • Utils in utils/.

You can use them without import statements. Trade-off: less explicit; needs IDE tooling to “go to definition” work. Most teams accept the trade-off; some disable with imports: { autoImport: false } in nuxt.config.

Q: Server-only vs universal code.

A: Code in server/ runs only on the server. Code anywhere else (pages/, components/, composables/) runs on both server (during SSR) and client (during interaction).

For server-only logic in a universal file, guard with import.meta.server:

if (import.meta.server) {
  const fs = await import("fs/promises");
  // ...
}

if (import.meta.client) {
  window.addEventListener("focus", ...);
}

Code that imports Node-only modules at the top level breaks the client build — that’s the most common SSR gotcha for Nuxt newcomers.

Q: useState — what’s it for?

A: Nuxt’s SSR-friendly state primitive — a ref whose value is serialized between server and client, and shared across components by key. Lightweight alternative to Pinia for simple cross-component state.

const counter = useState("counter", () => 0);
counter.value++;

If you call useState("counter") in two components, they share the same value. For complex state with actions, use Pinia.

Q: Layouts and pages.

A: pages/ directory = file-based routing. [id].vue for params, [...slug].vue for catch-all, (group)/ for groups without affecting URL.

layouts/ directory = persistent layouts. Default default.vue; override per-page:

<!-- pages/admin.vue -->
<script setup>
definePageMeta({ layout: "admin" });
</script>

layouts/admin.vue wraps the page with the admin chrome.

Q: Middleware.

A: Per-route logic that runs before navigation:

// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const user = useUserStore();
  if (!user.isAuthenticated) return navigateTo("/login");
});
<script setup>
definePageMeta({ middleware: ["auth"] });
</script>

Plus server middleware (server/middleware/) for request-level concerns (CORS headers, logging) and global middleware (middleware/*.global.ts).

Q: Modules — how does the ecosystem extend Nuxt?

A: Modules are first-class. Install + add to nuxt.config.ts:

export default defineNuxtConfig({
  modules: [
    "@pinia/nuxt",
    "@nuxtjs/tailwindcss",
    "@vueuse/nuxt",
    "@nuxtjs/i18n",
    "@nuxt/image",
  ],
});

Modules can add server routes, auto-imports, components, runtime config — they hook into the build/render pipeline. The ecosystem is the main reason to choose Nuxt over hand-rolled Vue SSR.

Q: Differences from Next.js (React).

A:

Nuxt 3 Next.js (App Router)
Routing pages/ (default) or app/ (Nuxt 4) app/
Data fetching useFetch / useAsyncData fetch + RSC, loader-style
Server components universal (server/api); no RSC equivalent RSC native
Rendering modes route rules (SSR/SSG/ISR/SPA per route) per-segment via dynamic / revalidate
Server engine Nitro Next’s own
Modules ecosystem first-class community packages

Nuxt’s per-route render mode rules are arguably cleaner than Next’s per-segment options. Next’s RSC is a bigger architectural innovation that Nuxt hasn’t matched.

Gotchas / edge cases

  • Importing Node-only modules at top level in universal files breaks client builds. Use dynamic import + import.meta.server guard.
  • useFetch doesn’t dedupe across components with different URLs but same data — use a shared useAsyncData("key", ...) with a stable key.
  • Hydration mismatches — server renders one HTML, client tries to render different — Vue warns. Common causes: rendering Date.now() or Math.random() server-side, branching on window/document without import.meta.client guard.
  • useState value persists across pages — that’s the feature, but it also means you can leak state. Reset explicitly on logout or route change if needed.
  • ISR revalidation in dev doesn’t behave the same as production — test with nuxt build && nuxt preview.
  • Server routes are not cached by default — set cache route rule or use Nitro’s cachedEventHandler.

What a senior is expected to say

  • “Nuxt 3 = Vue’s Next.js. File-based routing, Nitro server engine, per-route render modes via routeRules — SSR by default, SSG for static, ISR for occasionally-changing, SPA for authed.”
  • useFetch / useAsyncData are SSR-aware — data fetched on the server, serialized into HTML, no double-fetch on hydration. $fetch is the raw HTTP utility for handlers and server routes.”
  • “Server routes in server/api/* run only on the server via Nitro — Node APIs, DB clients, secrets are safe there.”
  • “Auto-imports are convenient but you trade explicitness for them; modern IDE tooling makes it survivable.”
  • “Hydration mismatches are the #1 SSR bug class — guard browser-only code with import.meta.client and don’t render time/random values without keying.”

Cross-references

Further reading