Introduction to Rendering Modes in Nuxt
Nuxt has established itself as one of the most powerful meta-frameworks built on top of Vue.js. One of its standout features is the ability to seamlessly switch between different rendering strategies without rewriting your application. In this tutorial, we will explore the three primary rendering modes available in Nuxt: Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). Understanding when and how to use each mode is essential for building performant, SEO-friendly, and scalable web applications.
Why Rendering Strategy Matters
The rendering strategy you choose directly impacts your application's performance, search engine visibility, infrastructure costs, and user experience. A traditional Single Page Application (SPA) renders everything in the browser, which can lead to slower initial loads and poor SEO. Nuxt solves this by offering multiple rendering modes that can be configured at the route level, giving developers fine-grained control over how content is delivered to users.
Understanding the Three Rendering Modes
Server-Side Rendering (SSR)
SSR generates HTML on the server for each incoming request. When a user navigates to a page, the server fetches the necessary data, renders the Vue components into HTML strings, and sends a fully composed page to the browser. This approach is ideal for content that changes frequently or is personalized per user.
- Best for: E-commerce sites, dashboards, personalized content, news sites
- Pros: Excellent SEO, fresh data on every request, good First Contentful Paint
- Cons: Higher server load, requires a running Node.js server, latency per request
Static Site Generation (SSG)
SSG pre-renders all pages at build time. The output is a set of static HTML files that can be served from any CDN. This approach delivers the fastest possible load times since there is no server computation at request time.
- Best for: Blogs, documentation sites, marketing pages, portfolios
- Pros: Maximum performance, low hosting cost, highly cacheable, secure
- Cons: Build time grows with content size, stale data until rebuild
Incremental Static Regeneration (ISR)
ISR bridges the gap between SSR and SSG. Pages are generated statically, but they can be regenerated in the background at configurable intervals or on-demand. This means users get the speed of static content while data stays reasonably fresh without requiring a full rebuild.
- Best for: Large content sites, product catalogs, frequently updated blogs
- Pros: Static performance with fresh data, reduced rebuild times, scalable
- Cons: Requires a compatible hosting platform, slight data staleness possible
Setting Up a Nuxt Project
Before diving into each rendering mode, let us create a fresh Nuxt project. Make sure you have Node.js version 18 or higher installed.
npx nuxi@latest init my-nuxt-app
cd my-nuxt-app
npm install
npm run dev
The default Nuxt configuration enables SSR out of the box. The rendering behavior is controlled primarily through the nuxt.config.ts file and route rules. Let us examine each mode in detail.
Configuring Server-Side Rendering
SSR is the default rendering mode in Nuxt. To explicitly enable or customize it, you modify the nuxt.config.ts file. The key setting is ssr, which defaults to true.
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
runtimeConfig: {
public: {
apiBase: process.env.API_BASE || 'https://api.example.com'
}
}
})
Fetching Data with SSR
To fetch data during server-side rendering, Nuxt provides the useFetch and useAsyncData composables. These composables run on the server during the initial request and hydrate on the client without duplicate requests.
<template>
<div>
<h1>Products</h1>
<ul>
<li v-for="product in products" :key="product.id">
{{ product.name }} - ${{ product.price }}
</li>
</ul>
</div>
</template>
<script setup>
const { data: products, pending, error } = await useFetch('/api/products')
if (error.value) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to load products'
})
}
</script>
The useFetch composable automatically handles serialization and deserialization of data between server and client. The payload is embedded in the HTML, so the client does not need to refetch the same data during hydration.
Using useAsyncData for Custom Logic
When you need more control over data fetching, useAsyncData is the preferred choice. It accepts a handler function that can contain any asynchronous logic.
<script setup>
const { data: user } = await useAsyncData('currentUser', async () => {
const response = await $fetch('/api/user/profile', {
headers: useRequestHeaders(['cookie'])
})
return response
})
</script>
Notice how we forward the cookie header from the incoming request using useRequestHeaders. This is critical for authenticated SSR pages, since the server needs the user's session cookie to fetch personalized data.
Configuring Static Site Generation
To generate a fully static site, you set ssr: true (which is the default) and then run the build command with the prerender flag. Nuxt will crawl your routes and generate static HTML for each one.
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
nitro: {
prerender: {
crawlLinks: true,
routes: ['/', '/about', '/blog'],
ignore: ['/admin']
}
}
})
Then run the generate command:
npm run generate
This produces a .output/public directory containing static HTML, CSS, JavaScript, and other assets. You can deploy this directory to any static hosting provider such as Netlify, Vercel, GitHub Pages, or Cloudflare Pages.
Prerendering Specific Routes
Sometimes you want a hybrid approach where most of the app uses SSR but certain routes are prerendered at build time. Nuxt route rules make this straightforward.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true },
'/about': { prerender: true },
'/blog/**': { prerender: true },
'/dashboard/**': { ssr: true },
'/admin/**': { ssr: false }
}
})
In this configuration, the homepage, about page, and all blog pages are prerendered as static HTML. Dashboard pages use SSR, and admin pages are rendered client-side only (SPA mode). This per-route flexibility is one of Nuxt's most powerful features.
Handling Dynamic Routes in SSG
For dynamic routes, such as blog posts with slugs, you need to tell Nuxt which paths to prerender. You can do this with the nitro.prerender.routes option or by using nuxt generate with a custom hook.
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
crawlLinks: true,
async routes() {
const posts = await $fetch('https://api.example.com/posts')
return posts.map(post => `/blog/${post.slug}`)
}
}
}
})
The crawlLinks option tells Nuxt to follow internal links found in prerendered pages, automatically discovering additional routes. Combined with an explicit route list for dynamic content, this ensures all pages are generated.
Implementing Incremental Static Regeneration
ISR allows you to serve static pages that regenerate in the background after a specified time period. This feature requires a hosting platform that supports Nitro's ISR capabilities, such as Vercel or Netlify.
Time-Based ISR
With time-based ISR, a page is regenerated if it is older than the specified interval. The first visitor after the interval receives the stale page while the regeneration happens in the background. Subsequent visitors get the fresh page.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/blog/**': {
isr: 3600 // Regenerate every hour (in seconds)
},
'/products/**': {
isr: 300 // Regenerate every 5 minutes
}
}
})
On-Demand ISR with Cache Tags
For more precise control, Nuxt supports on-demand ISR using cache tags. You tag pages when they are generated, and then invalidate specific tags when the underlying data changes.
// pages/blog/[slug].vue
<script setup>
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`, {
key: `post-${route.params.slug}`,
server: true
})
// Define cache tags for this page
defineRouteRules({
isr: {
name: `blog-${route.params.slug}`,
cacheTags: ['blog', `blog-${route.params.slug}`]
}
})
</script>
To invalidate the cache, you create a server API endpoint that purges the relevant tags:
// server/api/revalidate.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { secret, tags } = body
if (secret !== process.env.REVALIDATE_SECRET) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
await nitroApp.hooks.callHook('prerender:purge', tags)
return { revalidated: true, tags }
})
When you update a blog post in your CMS, you send a POST request to this endpoint with the appropriate cache tags, and Nuxt regenerates only the affected pages.
Hybrid Rendering: Combining Modes
One of the most compelling features of Nuxt is hybrid rendering, which lets you mix SSR, SSG, ISR, and SPA modes within the same application. The routeRules configuration is the central mechanism for this.
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
routeRules: {
// Prerendered at build time
'/': { prerender: true },
'/about': { prerender: true },
'/pricing': { prerender: true },
// ISR with 10-minute swr
'/blog/**': { isr: 600 },
// SSR on every request
'/dashboard/**': { ssr: true },
// Client-side only (SPA)
'/admin/**': { ssr: false },
// ISR with on-demand revalidation
'/products/**': { isr: { cacheTags: ['products'] } },
// Redirects
'/old-path': { redirect: '/new-path' },
// Custom headers
'/api/**': { cors: true }
}
})
This configuration demonstrates how a single application can serve marketing pages as static HTML, blog posts with ISR, a dashboard with fresh SSR, and an admin panel as a client-side SPA. The flexibility is unmatched.
Best Practices
Choose the Right Mode Per Route
Do not apply a single rendering mode to your entire application by default. Analyze each route's requirements. Marketing and documentation pages should be prerendered. Frequently updated content benefits from ISR. Personalized or real-time data pages need SSR. Interactive tools that do not need SEO can use SPA mode.
Avoid Waterfall Data Fetching
When multiple data fetches are needed on a page, avoid sequential awaits that create waterfalls. Use parallel fetching to reduce server response time.
<script setup>
// Bad: sequential fetching
const { data: user } = await useFetch('/api/user')
const { data: posts } = await useFetch(`/api/user/${user.value.id}/posts`)
// Good: parallel fetching with useAsyncData
const { data } = await useAsyncData('dashboard', async () => {
const [user, notifications] = await Promise.all([
$fetch('/api/user'),
$fetch('/api/notifications')
])
const posts = await $fetch(`/api/user/${user.id}/posts`)
return { user, posts, notifications }
})
</script>
Handle Errors Gracefully
Always handle data fetching errors in SSR. Unhandled errors during server rendering can crash the entire request. Use createError to throw meaningful errors that Nuxt can display properly.
<script setup>
const { data, error } = await useFetch('/api/articles')
if (error.value) {
throw createError({
statusCode: 404,
statusMessage: 'Articles not found',
fatal: true
})
}
</script>
Optimize Payload Size
The data fetched during SSR is serialized into the HTML payload. Avoid fetching more data than necessary. Select only the fields you need, and use pick or transform options to reduce payload size.
<script setup>
const { data: users } = await useFetch('/api/users', {
pick: ['id', 'name', 'avatar'],
transform: (users) => {
return users.map(u => ({
id: u.id,
displayName: u.name,
img: u.avatar
}))
}
})
</script>
Use Caching Headers Strategically
For SSR routes, set appropriate cache-control headers to leverage CDN caching. Nuxt route rules support a headers option for this purpose.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/api/public/**': {
headers: {
'cache-control': 'public, max-age=300, s-maxage=600'
}
}
}
})
Test Hydration Carefully
Hydration mismatches occur when the server-rendered HTML differs from what the client expects. Avoid using Date.now(), Math.random(), or browser-only APIs during initial render. Use import.meta.client to guard browser-specific code.
<script setup>
const currentTime = ref(null)
onMounted(() => {
currentTime.value = new Date().toLocaleTimeString()
})
</script>
<template>
<div>
<p v-if="currentTime">Current time: {{ currentTime }}</p>
<p v-else>Loading time...</p>
</div>
</template>
Deployment Considerations
Your hosting platform determines which rendering modes are available. Static hosting providers like GitHub Pages only support SSG output. Platforms like Vercel, Netlify, and Cloudflare Pages support SSR, SSG, and ISR. For self-hosted SSR, you can deploy the Nitro server output to any Node.js environment, or use Docker.
# Build for Node.js server deployment
npm run build
node .output/server/index.mjs
# Build for static hosting
npm run generate
# Deploy .output/public/ directory
For Docker deployments, Nuxt provides a preset that generates a Dockerfile:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'docker'
}
})
Conclusion
Nuxt's rendering architecture gives developers an exceptional level of flexibility. By understanding the strengths and trade-offs of SSR, SSG, and ISR, you can architect applications that deliver optimal performance, SEO, and freshness for each individual route. The key takeaway is that rendering is not an all-or-nothing decision. With route rules and hybrid rendering, you can combine multiple strategies within a single codebase, choosing the best approach for each page based on its specific needs. Start with the default SSR mode, identify which routes can be prerendered or cached with ISR, and progressively optimize your application. By following the best practices outlined in this tutorial, you will be well-equipped to build fast, scalable, and maintainable Nuxt applications that excel in both user experience and search visibility.