← Back to DevBytes

Server-Side Rendering with Vue.js: SSR, SSG, ISR

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

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles