Nuxt server-side rendering: a practical Vue & Nuxt guide
When a user lands on your Nuxt application, they shouldn't wait for JavaScript to download, parse, and execute before seeing meaningful content. Server-side rendering (SSR) solves this by sending fully-rendered HTML from the server on the first request. This guide walks you through Nuxt server-side rendering: a practical Vue & Nuxt guide that covers how to architect universal code, avoid hydration pitfalls, and ship fast-loading, SEO-friendly pages.
SSR by default — Pages render on the server, then hydrate
Nuxt 3 enables SSR out of the box. When a visitor navigates to your site, the server executes your Vue components, renders them to HTML strings, and sends that markup to the browser. Once the HTML arrives and paints on screen, Vue then hydrates it—attaching event listeners and state reactivity to the existing DOM. This two-step process means users see content instantly, and search engines receive complete HTML.
Here's what happens under the hood:
- User requests a page (e.g.,
/products/42). - Nuxt server runs your page component, fetches data, and renders it to HTML.
- Server sends HTML + JavaScript bundle to the browser.
- Browser displays HTML immediately.
- Vue hydrates: JavaScript loads, event listeners attach, app becomes interactive.
To see this in action, consider a product detail page. On the server, you fetch the product data and render the template. The browser receives:
<!-- Sent from server -->
<div class="product">
<h1>Awesome Widget</h1>
<p class="price">$29.99</p>
<button>Add to Cart</button>
</div>
<script src="/app.js"></script>The page is readable before any JavaScript executes. Then the app hydrates, and the "Add to Cart" button becomes reactive.
You disable SSR only when necessary—set ssr: false in nuxt.config.ts—but most projects benefit from keeping it enabled. The performance and SEO gains are substantial.
Universal code — Code runs on server and client — guard browser APIs
In a Nuxt app, most of your code runs in both environments: server during rendering and client after hydration. This universality is powerful but requires discipline. The server has no window, document, or localStorage. Accessing these during SSR throws errors.
A typical scenario: you want to read a user's theme preference from localStorage and apply it. On the server, there is no localStorage, so you must guard access:
// pages/index.vue
<script setup lang="ts">
const theme = ref('light');
onMounted(() => {
// Safe: onMounted only runs on the client
theme.value = localStorage.getItem('theme') || 'light';
});
</script>
<template>
<div :class="theme">
<h1>Hello</h1>
</div>
</template>Here, onMounted is a Vue lifecycle hook that fires only after hydration on the client, so localStorage access is safe.
Another approach: use process.server and process.client to conditionally run code:
// composables/useTheme.ts
export const useTheme = () => {
const theme = ref('light');
if (process.client) {
theme.value = localStorage.getItem('theme') || 'light';
}
return { theme };
};Or wrap entire components with <ClientOnly> (covered in the next section).
Consider also server-side data fetching. Use useFetch or useAsyncData in your page or layout, and they automatically run on the server first, then on the client when navigating:
// pages/products/[id].vue
<script setup lang="ts">
const { data: product } = await useFetch(`/api/products/${route.params.id}`);
</script>
<template>
<div v-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
</div>
</template>On the server, this fetch runs during rendering, so the HTML includes the full product data. The client then hydrates with that same data.
Hydration mismatches — Avoid random values and dates in templates
Hydration mismatches occur when the server renders different HTML than the client renders after hydration. The browser compares the server's HTML with what Vue generates on the client, and if they differ, Vue re-renders the entire component tree. This is expensive and breaks reactivity. Classic culprits: random numbers, timestamps, and computed values based on browser state.
A problematic example:
// pages/index.vue
<script setup lang="ts">
const randomId = Math.random().toString(36).slice(2);
const now = new Date().toLocaleString();
</script>
<template>
<div>
<p>Session ID: {{ randomId }}</p>
<p>Time: {{ now }}</p>
</div>
</template>The server generates a random ID and renders it. The browser hydrates and generates a different random ID, causing a mismatch. The time also differs between server and client execution.
Fix: generate these values after hydration or pass them via props:
// Approach 1: Generate on client only
<script setup lang="ts">
const randomId = ref('');
const now = ref('');
onMounted(() => {
randomId.value = Math.random().toString(36).slice(2);
now.value = new Date().toLocaleString();
});
</script>
<template>
<div>
<p v-if="randomId">Session ID: {{ randomId }}</p>
<p v-if="now">Time: {{ now }}</p>
</div>
</template>Or, use a key to force re-render only that subtree:
// Approach 2: Use ClientOnly
<template>
<div>
<ClientOnly>
<p>Session ID: {{ Math.random().toString(36).slice(2) }}</p>
<p>Time: {{ new Date().toLocaleString() }}</p>
</ClientOnly>
</div>
</template>With ClientOnly, that section skips server rendering and only renders on the client, eliminating the mismatch entirely.
ClientOnly — Wrap browser-only components
Not all components can render on the server. Libraries that depend on the DOM (charts, animations, third-party widgets) need ClientOnly. Wrapping them prevents server errors and hydration mismatches.
Imagine you're using a charting library:
// components/SalesChart.vue
<script setup lang="ts">
import Chart from 'chart.js';
onMounted(() => {
const ctx = document.getElementById('myChart');
new Chart(ctx, { /* config */ });
});
</script>
<template>
<canvas id="myChart"></canvas>
</template>This component accesses document.getElementById in onMounted, which is safe. However, if initialization happens in the script root or setup, the server will crash. Wrap it:
// pages/dashboard.vue
<template>
<div>
<h1>Dashboard</h1>
<ClientOnly>
<SalesChart />
<fallback>
<div>Loading chart...</div>
</fallback>
</ClientOnly>
</div>
</template>The <fallback> slot shows on the server and during hydration. Once the client hydrates, SalesChart mounts and renders the chart.
Another use case: third-party widgets (embeds, payment forms). Wrap them in ClientOnly to prevent server-side initialization:
// components/StripePaymentForm.vue
<template>
<ClientOnly>
<div id="card-element"></div>
<fallback>
<p>Loading payment form...</p>
</fallback>
</ClientOnly>
</template>Common mistakes
Mistake 1: Generating random IDs at the template root. As covered earlier, avoid {{ Math.random() }} in templates. Move it to onMounted or wrap in ClientOnly.
Mistake 2: Forgetting to check environment in composables. If a composable uses localStorage, guard it with process.client. A team member might call it from a page component expecting it to work universally, causing server errors.
Mistake 3: Over-using ClientOnly. Every ClientOnly delays content visibility. If a component can render on the server, it should. Reserve ClientOnly for truly DOM-dependent code.
Mistake 4: Setting ssr: false globally to avoid hydration issues. This is a band-aid. Disabling SSR means every user waits for JavaScript to execute before seeing content. Debug and fix the mismatch instead.
Mistake 5: Not seeding the same initial state on server and client. If you fetch data on the server and want to use it on the client without re-fetching, return it via props or pass it through the payload.
Key takeaways
- Nuxt SSR renders your app on the server and sends HTML to the browser, then hydrates it with JavaScript. This is enabled by default and significantly improves first-load performance and SEO.
- Write universal code that runs in both environments, but guard browser APIs like
windowandlocalStoragebehindprocess.clientchecks or lifecycle hooks likeonMounted. - Hydration mismatches happen when server and client render different HTML. Avoid random values and dates in templates during SSR; generate them after hydration or use
ClientOnly. - Use
<ClientOnly>for components that depend on the DOM or third-party browser APIs. Always provide a fallback slot for better UX. - Use
useFetchanduseAsyncDatato fetch data on the server, ensuring users receive fully-rendered pages with no initial loading spinners. - Test your app in production mode (
npm run build && npm run preview) to catch SSR issues before deployment.






Comments
Loading comments…