Skip to content
advanced Phase 14 · Performance Optimization and Server-Side Rendering

Nuxt for Full-Stack Vue Apps

Adopt file-based routing, server routes, and server data utilities.

50m
0 problems
Topic Progress 0%

Drop the router file: the pages directory becomes the route table

In a hand-rolled Vue app, adding a route means opening your router file, importing a view, and appending a path/component entry — and reviewing that diff for merge conflicts with everyone else's routes. Nuxt removes the file: the directory structure is the route table, generated at build time, with each page code-split by default.

pages/
├── index.vue              →  /
├── account/
│   ├── index.vue          →  /account
│   └── billing.vue        →  /account/billing
├── posts/
│   ├── index.vue          →  /posts
│   └── [id].vue           →  /posts/:id
└── [...slug].vue          →  /*   (catch-all)

The mapping rules are small and complete:

File Route Notes
pages/index.vue / every app needs one
pages/posts/index.vue /posts folders nest paths
pages/posts/[id].vue /posts/:id [param] → dynamic segment
pages/posts/[[id]].vue /posts/:id? double brackets make it optional
pages/[...slug].vue /:slug(.*)* catch-all, read via route.params.slug

Nested directories only become nested routes when the parent renders <NuxtPage /> — pages/account/index.vue inside pages/account.vue would otherwise never display. A dynamic page reads its segment through the familiar route object and fetches its own data during SSR:

<!-- pages/posts/[id].vue -->
<script setup>
const route = useRoute()
const { data: post } = await useAsyncData(
  `post-${route.params.id}`,
  () => $fetch(`/api/posts/${route.params.id}`),
)
</script>

<template>
  <article>
    <h1>{{ post.title }}</h1>
    <ContentRenderer :value="post.body" />
  </article>
</template>

Because the fetch awaits during SSR, the HTML arrives with the article already in it — no loading spinner on first paint, no second request after hydration. On client-side navigation the same call runs in the browser, so one component serves both render modes without a typeof window branch anywhere.

definePageMeta attaches per-page configuration without a wrapper component:

<script setup>
definePageMeta({
  middleware: 'auth',
  layout: 'dashboard',
})
</script>

<template>
  <article>
    <h1>{{ post.title }}</h1>
    <NuxtPage />
  </article>
</template>

middleware: 'auth' runs a file-defined route guard before the page renders — the same guard pattern you would have written in router.beforeEach, scoped to the pages that declare it:

// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const session = useCookie('session')
  if (!session.value) {
    return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
  }
})

The cookie is readable here because the middleware can run on the server during SSR, where document.cookie would not exist — the request carries it in headers, and Nuxt surfaces it through useCookie. And because every page file becomes its own chunk, the lazy-loading discipline from Async Components and Code Splitting is the default behavior rather than something you wire up per route; <NuxtLink> prefetches a page when its link scrolls into view, so navigation is instant without shipping every view up front.

Two things you will look for and not find: an import of the view, and a route entry. That is the point — but it also means typos become silent 404s instead of build errors, so pages/ naming deserves the same care as your URL scheme. Keep the file name equal to the URL segment you intend, and treat renames as breaking changes to production links. Your muscle memory still transfers where it should: NuxtLink replaces router-link (adding prefetch on visibility), useRouter() handles programmatic jumps like await navigateTo('/orders/42'), and useRoute().params reads segments exactly as vue-router does — the framework deletes configuration, not concepts.

The framework's second half removes the other file you maintain by hand: the backend endpoint module. Server routes in server/ become live API endpoints with no Express app to configure — the subject of the next chapter.

Turn server/api files into endpoints without a separate backend

The app needs /api/orders, /api/checkout, and a webhook receiver. In a plain Vue project that means a separate Node service, its own deployment, and a fetch layer pointed at its origin. Nuxt folds the API into the same repository: files under server/api/ are the endpoints.

server/
├── api/
│   ├── orders.get.ts          →  GET  /api/orders
│   ├── orders/[id].get.ts      →  GET  /api/orders/:id
│   ├── checkout.post.ts        →  POST /api/checkout
│   └── webhooks/stripe.post.ts →  POST /api/webhooks/stripe
├── utils/
│   └── db.ts                   →  shared, never exposed as a route
└── middleware/
    └── auth.ts                 →  runs before matched server routes

The .get. / .post. suffix on the filename pins the HTTP method — a POST to a .get.ts file is rejected before your code runs, and dropping the suffix accepts every method. Each file default-exports a Nitro event handler:

// server/api/orders.get.ts
import { defineEventHandler, getQuery, createError } from 'h3'

export default defineEventHandler(async (event) => {
  const { page = '1' } = getQuery(event)
  const orders = await db.orders.findMany({ take: 20, skip: (Number(page) - 1) * 20 })

  if (!orders.length) {
    throw createError({ statusCode: 404, statusMessage: 'No orders for page' })
  }

  return { orders }   // serialized to JSON automatically
})

getQuery parses the query string, readBody parses JSON payloads, getRouterParam reads [id], and returning a plain object serializes to JSON — no res.json() ceremony. Throwing createError({ statusCode }) produces the proper status line and error payload, which the client sees through $fetch's normal error path. Handlers import shared code freely (server/utils/db.ts, your auth helpers) without exposing it to the browser bundle: anything inside server/ never ships as client JavaScript, which is the security property that makes this directory more than a convenience.

From a page, the endpoint is one composable away:

<script setup>
const { data: orders } = await useFetch('/api/orders?page=1')
</script>

useFetch during SSR calls your handler server-side — over an internal function call, not the network — and embeds the result in the page payload, a mechanism the next chapter unpacks. The same URL works unchanged in the browser after hydration, so a component using it behaves identically in dev, prod, and tests. Nitro routes requests to your own endpoints in-process, so there is no port, no origin, and no latency to configure; when the handler must see the visitor's cookies during that internal pass, useRequestFetch() forwards the incoming request's headers for you — otherwise an authenticated /api/me call would run as an anonymous request on first render.

Errors keep their HTTP semantics end to end. createError({ statusCode: 404, statusMessage: 'Order not found', data: { orderId } }) produces that status on the response, $fetch on the client throws a typed error carrying it, and Nuxt renders error.vue with the same payload when it happens during SSR — no special-casing of "real" errors versus "framework" errors. Two endpoint shapes complete the picture: a webhook like server/api/webhooks/stripe.post.ts that needs the untouched payload reads it with readRawBody(event) instead of readBody, because signature verification must run over the exact bytes that arrived; and a validation-heavy endpoint can pair readValidatedBody with a schema library so malformed input dies at the boundary with a 400 rather than reaching your database layer.

Nitro, the engine underneath, is what lets these handlers deploy to a Node server, a serverless platform, or an edge worker without changing their code — the server/ abstraction stays put while the runtime changes. That is the honest trade-off to weigh: you gain a full-stack app in one repo and delete a service deployment, and you take on a framework-owned HTTP layer (h3/Nitro) whose request object is not Express's. If you already operate a mature backend, Nuxt server routes are optional — the pages directory alone is a valid adoption.

File-based routes and file-based endpoints cover the plumbing. What makes SSR actually pay off is fetching data once on the server and handing it to the client without a refetch — plus per-route control over which rendering mode each URL gets.

Fetch once on the server, then pick a rendering mode per route

The classic SSR failure looks like this: the server renders the orders page, the HTML arrives painted, and then the browser fires GET /api/orders again to fill data the server already had. Doubled work, a flash of empty content, and a database queried twice per visit. useAsyncData exists to make that second request disappear.

<script setup>
const { data, status, error } = await useAsyncData(
  'orders',                       // stable key — identity of this payload
  () => $fetch('/api/orders'),    // runs on the server during SSR
)
</script>

During SSR the function runs on the server, and Nuxt embeds the result in the page's payload under that key. After hydration the client reads the payload instead of calling the function again — one fetch, shared by both renders, which is also what keeps hydration mismatch away: both sides start from the identical data. A stale key is the common bug here: reuse the same key for a route with different parameters and Nuxt will happily serve the previous page's cached value, so build the key from the route (orders-${route.params.id}) or let useFetch derive it from the URL.

Rendering mode is chosen per route with routeRules in nuxt.config.ts, evaluated at build time:

export default defineNuxtConfig({
  routeRules: {
    '/':            { prerender: true },       // fully static HTML at build
    '/blog/**':      { swr: 3600 },             // server-render, cache 1 h
    '/products/**':  { isr: 300 },              // incremental static regeneration
    '/admin/**':     { ssr: false },            // client-only SPA
    '/api/**':       { cors: true, headers: { 'cache-control': 's-maxage=300' } },
  },
})

Each rule picks a strategy without touching a single component — marketing pages prerendered at build time, catalog pages cached and re-rendered on demand, the admin console rendered purely in the browser where auth state lives. This granularity is the practical answer to "should our app be SSR or SPA?": usually neither globally, but per route.

The third lever is partial hydration, Nuxt's answer to a page whose shell must be dynamic but whose widgets need no JavaScript. An island component renders on the server, ships as HTML plus a tiny event bridge, and never joins the client bundle:

<!-- islands/NewsletterForm.vue — no client JS unless an event fires -->
<template>
  <form @submit.prevent="submit">...</form>
</template>

Files in the islands/ directory (or referenced through <NuxtIsland>) render server-side and hydrate on demand, so a heavy static widget — a rendered README, a chart image generated per request — stops costing bundle bytes on every visit. Lazy-hydration directives (hydrate-on-visible on ordinary components) solve the adjacent problem: defer a below-the-fold component's client work until it scrolls into view.

The rendering choices line up as a spectrum, each trading freshness for cost:

Mode When HTML exists Client JS Best for
Prerender (ssr: false + prerender) build time full SPA landing, docs
SSR / SWR request, cached full personalized or changing pages
Island request, per component none (on demand) rich widgets inside dynamic pages
SPA (ssr: false) shell only full authenticated tools

Combine them and the shape of a production Nuxt app is complete: routes from pages/, endpoints from server/, data fetched once through useAsyncData, and a rendering mode chosen per route. You arrived here from Virtual Scrolling for Large Lists — the client-side half of keeping large pages fast — and this topic adds the server half of that same goal. Everything such an app needs from its deployment, mode-specific API URLs, public keys, feature flags, comes next in Environment Variables in Vite.

Quiz

1. What route does the file pages/posts/[id].vue generate?

Question 1 options

2. A page reads window.innerWidth during setup() to pick a layout. What goes wrong in a Nuxt SSR app?

Question 2 options

3. nuxt.config declares routeRules: { '/admin/**': { ssr: false } }. What does that produce?

Question 3 options

4. What does the key argument to useAsyncData('orders', fn) do?

Question 4 options

Flashcards

Question

How does Nuxt map the pages directory to routes?

Answer

File path = URL: index.vue → /, folders nest segments, [id].vue → :id, [[id]].vue → optional, [...slug].vue → catch-all. Nested routes require <NuxtPage /> in the parent, and each page is code-split automatically.

Question

How do server/api files work, and what never ships to the browser?

Answer

server/api/x.post.ts becomes POST /api/x via Nitro; the method suffix pins the verb, defineEventHandler with getQuery/readBody/getRouterParam handles input, createError sets status. Everything under server/ stays server-only — handlers and their imports never enter the client bundle.

Question

Why does useAsyncData avoid a double fetch, and what is the key's pitfall?

Answer

During SSR it runs on the server and its result is embedded in the payload; after hydration the client reads that payload under the same key instead of calling again. Build the key from the route params — a reused key serves a previous request's cached data.

Question

What do routeRules control, with one example of each common rule?

Answer

Per-route rendering and headers chosen at build time: prerender: true (static at build), swr/isr (cached server render), ssr: false (client-only SPA), cors and headers for API paths. Same app, different strategies per URL.

Question

When is an island component the right tool?

Answer

When a self-contained widget (rendered README, server-computed chart) needs server HTML but no client JS: islands ship markup plus an on-demand event bridge and never join the bundle — partial hydration instead of a fully hydrated or fully static page.

Revision Notes

Key Takeaways

  • 1. Nuxt generates the route table from pages/ — dynamic segments use [param], nesting needs <NuxtPage />, and pages ship code-split by default
  • 2. server/api files are real endpoints through Nitro: method suffix, getQuery/readBody, createError, and a hard boundary that keeps handlers out of the client bundle
  • 3. useAsyncData runs during SSR and embeds its result under a key, so hydration reuses the payload instead of refetching — the anti-pattern it kills is the double request per visit
  • 4. routeRules choose rendering per route (prerender, swr/isr, ssr:false, headers) — production apps usually mix strategies instead of picking one globally
  • 5. Island components hydrate on demand, shipping HTML without bundle cost — the middle ground between a fully static widget and a fully hydrated page

Interview Tips

  • • "How does Nuxt routing differ from vue-router setup?" — file path is the route, [id] is :id, definePageMeta attaches middleware/layout; mention that lazy imports per page are the default, so code splitting is structural
  • • "Where do I put an API in Nuxt?" — server/api with method suffixes; stress that server/ code and its imports never reach the browser, which is the security boundary, not just convenience
  • • "How does SSR avoid fetching data twice?" — useAsyncData key + payload: server fetches, payload embeds, client reads under the same key; call out the stale-key pitfall
  • • "SSR or SPA for a dashboard?" — answer per route with routeRules: ssr:false for the authenticated console, prerender for marketing, SWR for public pages — and name island components for heavy static widgets

Cheat Sheet

Nuxt for Full-Stack Vue Apps

File → route:

pages/index.vue          →  /
pages/posts/index.vue    →  /posts
pages/posts/[id].vue     →  /posts/:id      (params via useRoute().params)
pages/posts/[[id]].vue   →  /posts/:id?
pages/[...slug].vue      →  /*

Parent must render <NuxtPage />; definePageMeta({ middleware, layout }) scopes config; middleware/auth.ts runs before declared pages (cookie readable because it runs server-side).

Server route:

// server/api/orders.get.ts  →  GET /api/orders
export default defineEventHandler(async (event) => {
  const { page = '1' } = getQuery(event)
  if (!orders.length) throw createError({ statusCode: 404, statusMessage: 'No orders' })
  return { orders }        // JSON automatically; server/ never ships to the client
})

Data + rendering:

const { data } = await useAsyncData(`post-${route.params.id}`,
  () => $fetch(`/api/posts/${route.params.id}`))

routeRules: {
  '/':          { prerender: true },   // static at build
  '/blog/**':   { swr: 3600 },         // cached SSR
  '/admin/**':  { ssr: false },        // client-only
  '/api/**':    { cors: true },
}

Islands (islands/ dir, <NuxtIsland>) render server-side with no client bundle entry; lazy-hydration directives defer ordinary components until visible.

Continue Learning