advanced
frontend

SSR with Nuxt

Server-side rendering, hydration, data fetching, and deployment with Nuxt

vue fundamentals
34 min read

Server-side rendering (SSR) runs your Vue components on the server, sends fully-formed HTML to the browser, and then "hydrates" that HTML into a live, interactive app. The payoff is real: faster first paint, meaningful content for search-engine crawlers and social-preview scrapers, and less blank-screen time on slow devices. Nuxt is the framework that makes SSR practical in Vue 3 — it wires up a server engine (Nitro), a file-based router, auto-imported composables, and deployment presets so you can ship an SSR app without hand-rolling a Node server. This lesson covers how rendering and hydration actually work, how to fetch data correctly on both server and client, how to keep secrets on the server, and how to deploy.

Learning Objectives
  • Explain SSR, hydration, and how they differ from client-only rendering
  • Fetch data safely with useFetch and useAsyncData
  • Keep secrets server-side with runtimeConfig and server routes
  • Configure hybrid rendering with routeRules
  • Build and deploy a Nuxt app with Nitro presets

How SSR and hydration work

In a plain SPA, the browser downloads an empty <div id="app">, then JavaScript builds the entire page client-side. With SSR, Nuxt runs your components on the server first, produces the final HTML, and ships it in the initial response. The browser paints that HTML immediately. Then the same components run again in the browser and attach event listeners to the existing DOM — this is hydration. After hydration, the page behaves like a normal SPA.

Fetching data with useFetch and useAsyncData

The naive approach — calling $fetch directly in setup — fetches the data on the server, then fetches it again on the client after hydration, doubling your API load. Nuxt solves this with useFetch and useAsyncData, which fetch once on the server and serialize the result into the HTML payload so the client reuses it instead of refetching.

vue
<script setup lang="ts"> // useFetch is the high-level wrapper: URL in, reactive state out. const { data: posts, pending, error, refresh } = await useFetch('/api/posts', { key: 'posts-list', // stable cache key across server/client lazy: false, // block navigation until data resolves (default) default: () => [], // value used before the request resolves }) </script> <template> <p v-if="pending">Loading…</p> <p v-else-if="error">Failed to load: {{ error.message }}</p> <ul v-else> <li v-for="post in posts" :key="post.id">{{ post.title }}</li> </ul> </template>

Reach for useAsyncData when the logic is more than a single URL — combining calls, running an SDK, or transforming a response before it hits the payload:

ts
const { data: dashboard } = await useAsyncData('dashboard', async () => { const [stats, activity] = await Promise.all([ $fetch('/api/stats'), $fetch('/api/activity'), ]) return { stats, activity } }, { transform: (d) => ({ ...d, total: d.stats.a + d.stats.b }), watch: [], // re-run when reactive deps change })

Keeping secrets on the server

A bundle sent to the browser is public — anyone can read it in DevTools. API keys, database URLs, and tokens must never appear in client code. Nuxt gives you two tools: runtimeConfig for values, and server routes for logic.

ts
// nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { // Private — server-only. Never exposed to the client bundle. stripeSecret: '', // set via NUXT_STRIPE_SECRET apiBase: 'https://api.internal', public: { // Exposed to the client. Safe values only. siteUrl: 'https://example.com', // set via NUXT_PUBLIC_SITE_URL }, }, })

Values default from nuxt.config.ts but are overridden at runtime by environment variables: private keys use the NUXT_ prefix and public keys use NUXT_PUBLIC_, both converted from camelCase to SCREAMING_SNAKE_CASE (so stripeSecret ← NUXT_STRIPE_SECRET). Read them with useRuntimeConfig():

ts
// server/api/checkout.post.ts export default defineEventHandler(async (event) => { const config = useRuntimeConfig(event) // full config on the server const body = await readBody(event) const session = await createStripeSession(config.stripeSecret, body.items) return { url: session.url } })

Server routes live in server/api/ and use Nitro's defineEventHandler. The filename encodes the HTTP method (checkout.post.ts → POST /api/checkout) and brackets create params (server/api/users/[id].ts → GET /api/users/:id).

ts
// server/api/posts.get.ts export default defineEventHandler(async (event) => { const { page = '1' } = getQuery(event) const config = useRuntimeConfig(event) return await $fetch(`${config.apiBase}/posts`, { query: { page }, headers: { authorization: `Bearer ${config.apiToken}` }, }) })

Hybrid rendering with routeRules

You rarely want one rendering mode for the whole app. Nuxt's routeRules lets you pick a strategy per route — prerender the landing page, cache the blog with stale-while-revalidate, and ship the admin dashboard as a client-only SPA — all in one build.

ts
// nuxt.config.ts export default defineNuxtConfig({ routeRules: { '/': { prerender: true }, // static at build time '/blog/**': { swr: 3600 }, // cache 1h, revalidate in background '/products/**': { isr: true }, // incremental static regeneration '/admin/**': { ssr: false }, // client-only SPA (no server render) '/api/legacy/**': { proxy: 'https://old.example.com/**' }, '/docs': { redirect: '/documentation' }, }, })

Avoiding hydration mismatches

Hydration expects the server-rendered HTML and the client's first render to be identical. When they differ, Vue logs a "Hydration node mismatch" warning and discards the server markup for that subtree — losing the SSR benefit and sometimes flashing wrong content. The usual causes are non-deterministic values (Date.now(), Math.random()) and browser-only APIs read during render.

vue
<script setup lang="ts"> // BAD: the server timestamp and client timestamp never match. // const now = new Date().toLocaleTimeString() // GOOD: compute browser-only values after hydration. const now = ref('') onMounted(() => { now.value = new Date().toLocaleTimeString() }) </script> <template> <!-- Wrap genuinely client-only widgets so they skip SSR entirely. --> <ClientOnly> <LiveChart /> <template #fallback><span>Loading chart…</span></template> </ClientOnly> </template>

For state that must be shared across components and survive the server-to-client transfer, use useState rather than a module-level ref — useState is serialized into the payload and is not shared between concurrent requests on the server.

ts
// Safe SSR state: unique per request, hydrated on the client. const counter = useState('counter', () => 0)

Building and deploying

Nuxt's server engine, Nitro, compiles your app for a target platform using a "preset." Many hosts are auto-detected, so the same codebase deploys to serverless, edge, or a long-running Node process without code changes.

Common Pitfalls

Server routes keep secrets safe
Place API keys in runtimeConfig (no public) and call third-party services from server/api/ handlers — never from a component, so credentials stay out of the client bundle.

Next Steps

Continue Learning
Vitest + Vue Test Utils.

Next Lesson

Testing Vue Applications