Introduction to Rendering Strategies in Vue.js
Modern web applications demand fast initial loads, good SEO, and excellent user experience. Vue.js, traditionally a client-side rendering (CSR) framework, has evolved to support multiple rendering strategies that address these needs. In this tutorial, we'll explore three key approaches: Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). We'll use Nuxt 3, the most popular Vue.js meta-framework, as our primary tool because it provides first-class support for all three strategies.
Understanding the Rendering Landscape
Client-Side Rendering (CSR)
In traditional CSR, the server sends an almost empty HTML file along with JavaScript bundles. The browser downloads the JavaScript, parses it, and then renders the content. This approach offers smooth navigation after the initial load but suffers from slower first contentful paint and poor SEO because crawlers see an empty page initially.
Server-Side Rendering (SSR)
SSR renders the Vue application on the server for each request. The server generates complete HTML and sends it to the browser. The user sees the content immediately, and search engines can index it properly. After the HTML loads, the client-side JavaScript "hydrates" the page, making it interactive.
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 offers the best performance because there's no server computation at request time. However, it's only suitable for content that doesn't change frequently.
Incremental Static Regeneration (ISR)
ISR combines the benefits of SSG and SSR. Pages are generated statically at build time, but they can be regenerated in the background at runtime when a request comes in and the cached version is stale. This gives you the performance of static pages with the freshness of server-rendered content.
Why Rendering Strategy Matters
- Performance: SSR and SSG deliver content faster to the user, improving Core Web Vitals scores like Largest Contentful Paint (LCP).
- SEO: Search engines can fully crawl pre-rendered HTML, leading to better indexing and ranking.
- Social Sharing: Pre-rendered pages provide proper Open Graph tags and preview content when shared on social media.
- Accessibility: Content is available even before JavaScript loads, helping users on slow connections or with JavaScript disabled.
- Server Cost: SSG and ISR reduce server load compared to SSR, since pages are cached and reused.
Setting Up a Nuxt 3 Project
Let's start by creating a new Nuxt 3 project. Nuxt 3 provides built-in support for SSR, SSG, and ISR, making it the ideal framework for this tutorial.
# Create a new Nuxt 3 project
npx nuxi@latest init my-vue-ssr-app
# Navigate to the project directory
cd my-vue-ssr-app
# Install dependencies
npm install
# Start the development server
npm run dev
By default, Nuxt 3 runs in SSR mode during development. The project structure includes an app.vue file as the entry point and a pages/ directory for file-based routing.
Server-Side Rendering (SSR) in Depth
How SSR Works in Nuxt
When a user requests a page, the Nuxt server executes the Vue components, fetches any required data, and generates a complete HTML string. This HTML is sent to the browser along with the client-side JavaScript. Once the JavaScript loads, it attaches event listeners and reactivity to the existing DOM in a process called hydration.
Fetching Data on the Server
Nuxt 3 provides the useFetch and useAsyncData composables for data fetching. These composables run on both the server during SSR and on the client during navigation. The fetched data is serialized and sent to the client to avoid duplicate requests during hydration.
<!-- pages/users.vue -->
<script setup>
const { data: users, pending, error, refresh } = await useFetch(
'https://jsonplaceholder.typicode.com/users'
)
</script>
<template>
<div>
<h1>User List</h1>
<p v-if="pending">Loading users...</p>
<p v-else-if="error">Error loading users: {{ error.message }}</p>
<ul v-else>
<li v-for="user in users" :key="user.id">
<NuxtLink :to="`/users/${user.id}`">
{{ user.name }} - {{ user.email }}
</NuxtLink>
</li>
</ul>
<button @click="refresh">Refresh</button>
</div>
</template>
Using useAsyncData for Custom Logic
When you need more control over data fetching, useAsyncData is the better choice. It accepts a handler function where you can implement custom logic.
<!-- pages/products.vue -->
<script setup>
const { data: products } = await useAsyncData('products', async () => {
// Custom data fetching logic
const response = await $fetch('https://api.example.com/products')
// Transform the data before returning
return response.map(product => ({
id: product.id,
name: product.name,
price: `$${product.price.toFixed(2)}`,
inStock: product.inventory > 0
}))
})
// Server-only logic
if (import.meta.server) {
console.log('This runs only on the server during SSR')
}
// Client-only logic
if (import.meta.client) {
console.log('This runs only on the client')
}
</script>
<template>
<div>
<h1>Products</h1>
<div v-for="product in products" :key="product.id">
<h2>{{ product.name }}</h2>
<p>Price: {{ product.price }}</p>
<p v-if="product.inStock">In Stock</p>
<p v-else>Out of Stock</p>
</div>
</div>
</template>
Configuring SSR Settings
You can fine-tune SSR behavior in the nuxt.config.ts file. You can disable SSR globally or configure specific options.
// nuxt.config.ts
export default defineNuxtConfig({
// Enable or disable SSR globally (default: true)
ssr: true,
// Configure nitro server options
nitro: {
preset: 'node-server',
routeRules: {
// Disable SSR for specific routes
'/admin/**': { ssr: false }
}
},
// Global app configuration
app: {
head: {
title: 'My SSR Vue App',
meta: [
{ name: 'description', content: 'A demo of SSR with Vue.js and Nuxt 3' }
]
}
}
})
Handling Server-Only Components
Nuxt 3 allows you to create components that only render on the server. These components are useful for tasks like fetching sensitive data or rendering heavy content that doesn't need to be interactive on the client.
<!-- components/ServerOnlyStats.server.vue -->
<script setup>
// This component only runs on the server
// It will never be shipped to the client
const stats = await $fetch('https://api.example.com/stats', {
headers: {
'Authorization': `Bearer ${process.env.API_SECRET}`
}
})
</script>
<template>
<div class="stats">
<h2>Platform Statistics</h2>
<p>Total Users: {{ stats.totalUsers }}</p>
<p>Active Today: {{ stats.activeToday }}</p>
<p>Total Posts: {{ stats.totalPosts }}</p>
</div>
</template>
Static Site Generation (SSG) in Depth
Pre-rendering All Routes
To generate a fully static site, you can use the nuxi generate command. This command pre-renders all routes discovered through the pages/ directory and any routes returned by the crawlLinks option.
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
// Automatically crawl links to discover routes
crawlLinks: true,
// Routes to pre-render explicitly
routes: [
'/',
'/about',
'/contact'
],
// Ignore specific routes
ignore: [
'/admin/**'
]
}
}
})
# Generate the static site
npm run generate
# The output will be in the .output/public directory
# You can serve it with any static file server
npx serve .output/public
Dynamic Routes and Pre-rendering
For dynamic routes, you need to tell Nuxt which paths to pre-render. You can do this by returning an array of routes from the routes option or by using the nitro:config hook.
<!-- pages/blog/[slug].vue -->
<script setup>
const route = useRoute()
const { data: post } = await useFetch(
() => `https://api.example.com/posts/${route.params.slug}`
)
</script>
<template>
<article>
<h1>{{ post.title }}</h1>
<div v-html="post.content"></div>
</article>
</template>
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
routes: async () => {
// Fetch all blog post slugs at build time
const posts = await $fetch('https://api.example.com/posts')
return posts.map(post => `/blog/${post.slug}`)
}
}
}
})
Pre-rendering with Custom Data
Sometimes you need to pass custom data to pre-rendered pages. You can use the payload feature to include data that will be extracted during the build and made available on the client without additional API calls.
// server/routes/blog-posts.ts
export default defineEventHandler(async (event) => {
const posts = await $fetch('https://api.example.com/posts')
return posts
})
<!-- pages/blog/index.vue -->
<script setup>
// During pre-rendering, this fetches from the server route
// The result is saved as a payload and loaded on the client
const { data: posts } = await useFetch('/api/blog-posts')
</script>
<template>
<div>
<h1>Blog</h1>
<NuxtLink v-for="post in posts" :key="post.id" :to="`/blog/${post.slug}`">
<h2>{{ post.title }}</h2>
<p>{{ post.excerpt }}</p>
</NuxtLink>
</div>
</template>
Incremental Static Regeneration (ISR) in Depth
How ISR Works
ISR allows you to serve static pages while periodically regenerating them in the background. When a request comes in, the server checks if the cached page is still fresh. If it is, the cached version is served immediately. If the cache is stale, the server serves the stale version while regenerating a new one in the background. The next request will receive the freshly generated page.
Configuring ISR with Route Rules
Nuxt 3 makes ISR configuration straightforward through route rules. You can set a swr (stale-while-revalidate) time for any route.
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
routeRules: {
// Regenerate the blog index every 60 seconds
'/blog': { swr: 60 },
// Regenerate individual blog posts every 300 seconds (5 minutes)
'/blog/**': { swr: 300 },
// Pre-render the about page at build time (SSG)
'/about': { prerender: true },
// Always render on the server (SSR)
'/dashboard/**': { ssr: true },
// Render only on the client (SPA mode)
'/admin/**': { ssr: false }
}
}
})
ISR with Dynamic Data
Let's build a practical example: a news site that uses ISR to keep content fresh without sacrificing performance.
<!-- pages/news/index.vue -->
<script setup>
// This data is cached and regenerated based on the route rule
const { data: articles, refresh } = await useFetch('/api/news', {
// Optional: set a default key for caching
key: 'news-list'
})
// Set up auto-refresh on the client
onMounted(() => {
const interval = setInterval(refresh, 60000)
onUnmounted(() => clearInterval(interval))
})
</script>
<template>
<div>
<h1>Latest News</h1>
<article v-for="article in articles" :key="article.id">
<NuxtLink :to="`/news/${article.slug}`">
<h2>{{ article.title }}</h2>
</NuxtLink>
<p>{{ article.summary }}</p>
<time>{{ new Date(article.publishedAt).toLocaleDateString() }}</time>
</article>
</div>
</template>
<!-- pages/news/[slug].vue -->
<script setup>
const route = useRoute()
const { data: article } = await useFetch(
() => `/api/news/${route.params.slug}`,
{
key: () => `news-${route.params.slug}`
}
)
// Update the page title for SEO
useHead({
title: () => article.value?.title || 'News Article',
meta: [
{
name: 'description',
content: () => article.value?.summary || ''
}
]
})
</script>
<template>
<article v-if="article">
<h1>{{ article.title }}</h1>
<time>{{ new Date(article.publishedAt).toLocaleDateString() }}</time>
<div v-html="article.content"></div>
<NuxtLink to="/news">Back to News</NuxtLink>
</article>
</template>
// server/api/news/index.ts
export default defineEventHandler(async (event) => {
// Fetch news from an external API or database
const articles = await $fetch('https://news-api.example.com/articles', {
query: {
limit: 20,
sort: 'publishedAt:desc'
}
})
return articles
})
// server/api/news/[slug].ts
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
const article = await $fetch(`https://news-api.example.com/articles/${slug}`)
if (!article) {
throw createError({
statusCode: 404,
statusMessage: 'Article not found'
})
}
return article
})
On-Demand Revalidation
In addition to time-based regeneration, you can implement on-demand revalidation. This is useful when you want to regenerate a page immediately after content changes, such as when a new article is published through a CMS.
// server/api/revalidate.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Verify a secret token to prevent unauthorized revalidation
const authHeader = getHeader(event, 'authorization')
if (authHeader !== `Bearer ${process.env.REVALIDATION_TOKEN}`) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized'
})
}
// Get the path to revalidate from the request body
const path = body.path
if (!path) {
throw createError({
statusCode: 400,
statusMessage: 'Path is required'
})
}
// Purge the cache for the specified path
await nitroApp.hooks.callHook('prerender:route', { path })
return {
revalidated: true,
path
}
})
You can then call this endpoint from your CMS webhook when content is updated:
// Example: CMS webhook handler
async function onContentUpdate(slug) {
await fetch('https://your-app.com/api/revalidate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${REVALIDATION_TOKEN}`
},
body: JSON.stringify({
path: `/news/${slug}`
})
})
}
Combining Rendering Strategies
One of the most powerful features of Nuxt 3 is the ability to mix rendering strategies within a single application. Different routes can use different strategies based on their requirements.
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
routeRules: {
// Static pages - pre-rendered at build time
'/': { prerender: true },
'/about': { prerender: true },
'/privacy': { prerender: true },
// Blog with ISR - regenerated every 5 minutes
'/blog': { swr: 300 },
'/blog/**': { swr: 300 },
// E-commerce product pages - ISR with shorter TTL
'/products/**': { swr: 60 },
// User dashboard - always SSR for personalized content
'/dashboard/**': { ssr: true },
// Admin panel - client-side only
'/admin/**': { ssr: false },
// API routes - ISR with 10 second TTL
'/api/featured': { swr: 10 }
}
}
})
Best Practices
Choose the Right Strategy per Route
- Use SSG for content that rarely changes: landing pages, documentation, about pages.
- Use ISR for content that updates periodically: blog posts, product catalogs, news articles.
- Use SSR for personalized, real-time content: user dashboards, search results, live data.
- Use CSR for highly interactive, non-SEO-critical pages: admin panels, internal tools.
Optimize Data Fetching
Avoid waterfall requests by using Promise.all when fetching multiple independent data sources. Use the key option in useFetch and useAsyncData to control caching and deduplication.
<script setup>
// Parallel data fetching - both requests run simultaneously
const [{ data: user }, { data: posts }] = await Promise.all([
useFetch('/api/user', { key: 'current-user' }),
useFetch('/api/posts', { key: 'user-posts' })
])
// Dependent data fetching - second request depends on the first
const { data: user } = await useFetch('/api/user')
const { data: orders } = await useFetch(
() => `/api/users/${user.value.id}/orders`,
{ key: 'user-orders' }
)
</script>
Handle Hydration Mismatches
Hydration mismatches occur when the server-rendered HTML differs from what the client expects. Common causes include using Date.now(), Math.random(), or accessing browser-only APIs during SSR. Use <ClientOnly> to wrap components that depend on browser APIs.
<template>
<div>
<h1>Dashboard</h1>
<!-- This renders on both server and client -->
<p>Welcome back, {{ user.name }}!</p>
<!-- This only renders on the client -->
<ClientOnly>
<InteractiveChart :data="chartData" />
<template #fallback>
<div class="chart-placeholder">Loading chart...</div>
</template>
</ClientOnly>
</div>
</template>
Optimize Bundle Size
Large JavaScript bundles slow down hydration and interactivity. Use code splitting, lazy-loaded components, and tree-shaking to keep bundles small.
<script setup>
// Lazy-load heavy components
const HeavyChart = defineAsyncComponent(() => import('~/components/HeavyChart.vue'))
// Lazy-load third-party libraries
const loadLibrary = async () => {
const { default: library } = await import('heavy-library')
return library
}
</script>
Set Proper Cache Headers
Configure cache headers for your API routes and static assets to improve performance and reduce server load.
// server/api/products.ts
export default defineEventHandler(async (event) => {
// Set cache headers
setResponseHeaders(event, {
'Cache-Control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=600'
})
const products = await $fetch('https://api.example.com/products')
return products
})
Handle Errors Gracefully
Implement proper error handling for both server and client. Create custom error pages and handle API failures gracefully.
<!-- error.vue -->
<script setup>
const props = defineProps({
error: Object
})
const handleError = () => clearError({ redirect: '/' })
</script>
<template>
<div class="error-page">
<h1>{{ error?.statusCode || 500 }}</h1>
<p>{{ error?.message || 'Something went wrong' }}</p>
<button @click="handleError">Go Home</button>
</div>
</template>
Monitor Performance
Use tools like Lighthouse, WebPageTest, and Vue DevTools to monitor your application's performance. Pay attention to Core Web Vitals: LCP (Largest Contentful Paint), FID/INP (First Input Delay / Interaction to Next Paint), and CLS (Cumulative Layout Shift).
Deployment Considerations
Different rendering strategies require different hosting environments. SSR needs a Node.js server or serverless function. SSG can be deployed to any static host. ISR requires a serverless or edge runtime that supports caching.
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
// Deploy to Vercel (supports SSR, SSG, and ISR natively)
preset: 'vercel',
// Or deploy to Netlify
// preset: 'netlify',
// Or deploy to Cloudflare Pages
// preset: 'cloudflare-pages',
// Or deploy as a Node.js server
// preset: 'node-server',
}
})
Conclusion
Server-Side Rendering, Static Site Generation, and Incremental Static Regeneration are powerful strategies that address different needs in modern web development. SSR provides real-time, personalized content with good SEO. SSG delivers the best possible performance for static content. ISR bridges the gap by offering static-like performance with dynamic content freshness. With Vue.js and Nuxt 3, you can combine all three strategies in a single application, choosing the optimal approach for each route based on its specific requirements. By understanding the trade-offs between these strategies and following best practices for data fetching, hydration, caching, and error handling, you can build Vue.js applications that are fast, SEO-friendly, and maintainable. Start by evaluating your routes and their content update frequency, then apply the appropriate rendering strategy to each one. The flexibility of Nuxt's route rules makes it easy to iterate and adjust your strategy as your application evolves.