Nuxt 3: SSR and Static Generation Complete Guide

Master SSR and static generation with Nuxt 3. From useFetch to route rules, learn how to optimize performance for your Vue.js applications.

Illustration showing server-side rendering and static generation with Nuxt 3 and Vue.js

Nuxt 3 transforms how Vue.js applications are built by offering multiple rendering modes suited to different use cases. From Server-Side Rendering (SSR) to static generation and hybrid rendering, the framework provides remarkable flexibility for optimizing performance and SEO.

Prerequisites

This tutorial assumes basic knowledge of Vue 3 and the Composition API. Familiarity with server-side rendering concepts is helpful but not required, as fundamentals are explained throughout the guide.

Understanding Nuxt 3 Rendering Modes

Before diving into code, understanding the differences between available rendering modes is essential. Each mode addresses specific needs in terms of performance, SEO, and user experience.

SSR (Server-Side Rendering) generates HTML on the server for each request. Static generation (SSG) pre-generates all pages at build time. Hybrid mode allows combining these approaches on a per-page basis.

nuxt.config.tstypescript
// Configuration for different rendering modes
export default defineNuxtConfig({
  // SSR enabled by default (recommended for SEO)
  ssr: true,

  // Static generation: pre-renders all pages
  // Use 'npm run generate' to build
  // target: 'static', // Nuxt 2 syntax

  // Hybrid mode: configurable per route
  routeRules: {
    // Homepage: pre-rendered and cached
    '/': { prerender: true },
    // Blog: static generation
    '/blog/**': { prerender: true },
    // Dashboard: client-side rendering only
    '/dashboard/**': { ssr: false },
    // API: no pre-rendering
    '/api/**': { prerender: false }
  }
})

This configuration demonstrates the power of hybrid mode: each section of the application uses the rendering mode best suited to its needs.

Data Fetching with useFetch and useAsyncData

Nuxt 3 provides two main composables for fetching data isomorphically. These composables work both server-side and client-side, with automatic hydration management.

useFetch is a wrapper around useAsyncData that simplifies HTTP calls. useAsyncData offers more control for advanced use cases.

vue
<script setup lang="ts">
// pages/blog/[slug].vue
// Article detail page with useFetch

// Get route parameter
const route = useRoute()

// useFetch: automatic data fetching
// Data is fetched server-side then hydrated on client
const { data: article, pending, error } = await useFetch(
  `/api/articles/${route.params.slug}`,
  {
    // Unique key for caching and deduplication
    key: `article-${route.params.slug}`,
    // Transform data if needed
    transform: (response) => response.data,
    // Cache options
    getCachedData: (key) => {
      // Check if data is cached
      const nuxtApp = useNuxtApp()
      return nuxtApp.payload.data[key]
    }
  }
)

// Error handling with navigation
if (error.value) {
  throw createError({
    statusCode: 404,
    message: 'Article not found'
  })
}
</script>

<template>
  <div>
    <div v-if="pending" class="loading">
      Loading article...
    </div>
    <article v-else-if="article">
      <h1>{{ article.title }}</h1>
      <div v-html="article.content" />
    </article>
  </div>
</template>

For cases requiring more control, useAsyncData allows executing any asynchronous function.

vue
<script setup lang="ts">
// pages/products/index.vue
// Product list with useAsyncData and filters

const route = useRoute()

// useAsyncData: full control over fetching logic
const { data: products, refresh } = await useAsyncData(
  'products-list',
  async () => {
    // Fetch from multiple sources if needed
    const [productsResponse, categoriesResponse] = await Promise.all([
      $fetch('/api/products', {
        query: {
          category: route.query.category,
          sort: route.query.sort || 'date'
        }
      }),
      $fetch('/api/categories')
    ])

    // Combine and transform data
    return {
      products: productsResponse.data,
      categories: categoriesResponse.data,
      total: productsResponse.meta.total
    }
  },
  {
    // Refresh when query params change
    watch: [() => route.query]
  }
)

// Manual refresh function
const updateFilters = async (newCategory: string) => {
  await navigateTo({
    query: { ...route.query, category: newCategory }
  })
}
</script>

These composables prevent double-fetching: data retrieved on the server is serialized into the HTML payload and reused during client-side hydration.

Customizing SSR with Server Hooks

Nuxt 3's SSR can be customized through server hooks. These hooks allow intervention at different stages of the rendering cycle to modify default behavior.

server/plugins/render-hooks.tstypescript
// Server plugin to customize SSR rendering

export default defineNitroPlugin((nitroApp) => {
  // Hook executed before rendering each page
  nitroApp.hooks.hook('render:html', (html, { event }) => {
    // Inject scripts or metadata
    html.head.push(`
      <script>
        // Analytics or global configuration
        window.__APP_CONFIG__ = {
          environment: '${process.env.NODE_ENV}',
          apiUrl: '${process.env.API_URL}'
        }
      </script>
    `)
  })

  // Hook for render cache management
  nitroApp.hooks.hook('render:response', (response, { event }) => {
    // Add custom cache headers
    const path = event.path

    if (path.startsWith('/blog/')) {
      // Long cache for blog articles
      response.headers['Cache-Control'] = 'public, max-age=3600, s-maxage=86400'
    } else if (path.startsWith('/api/')) {
      // No cache for APIs
      response.headers['Cache-Control'] = 'no-store'
    }
  })
})
SSR Performance

The render:response hook is ideal for implementing HTTP cache strategies. Combining SSR with a CDN that respects Cache-Control headers enables serving pre-rendered pages while maintaining the ability to invalidate them.

Static Generation with nuxt generate

Static generation pre-builds all pages at build time. This approach is ideal for sites with stable content like blogs, documentation, or marketing sites.

For dynamic routes, Nuxt needs to know all URLs to generate. The prerender:routes hook allows defining these routes programmatically.

nuxt.config.tstypescript
// Complete configuration for static generation

export default defineNuxtConfig({
  // Enable static generation
  nitro: {
    prerender: {
      // Enable automatic link crawling
      crawlLinks: true,
      // Routes to always include
      routes: ['/', '/about', '/contact'],
      // Ignore certain routes
      ignore: ['/admin', '/api']
    }
  },

  hooks: {
    // Hook to generate dynamic routes
    async 'prerender:routes'(ctx) {
      // Fetch articles from API or DB
      const articles = await fetch('https://api.example.com/articles')
        .then(res => res.json())

      // Add article routes
      for (const article of articles) {
        ctx.routes.add(`/blog/${article.slug}`)
      }

      // Fetch categories
      const categories = await fetch('https://api.example.com/categories')
        .then(res => res.json())

      for (const category of categories) {
        ctx.routes.add(`/category/${category.slug}`)
      }
    }
  }
})

For projects with many pages, the automatic crawler may be insufficient. Here's a more robust approach with a separate configuration file.

server/utils/generate-routes.tstypescript
// Utility to generate dynamic route list

import { prisma } from './prisma'

export async function getAllStaticRoutes(): Promise<string[]> {
  const routes: string[] = []

  // Blog articles
  const articles = await prisma.article.findMany({
    where: { published: true },
    select: { slug: true, category: { select: { slug: true } } }
  })

  for (const article of articles) {
    routes.push(`/blog/${article.category.slug}/${article.slug}`)
  }

  // Product pages
  const products = await prisma.product.findMany({
    where: { active: true },
    select: { slug: true }
  })

  for (const product of products) {
    routes.push(`/products/${product.slug}`)
  }

  // Tag pages
  const tags = await prisma.tag.findMany({
    select: { slug: true }
  })

  for (const tag of tags) {
    routes.push(`/tags/${tag.slug}`)
  }

  return routes
}

Ready to ace your Vue.js / Nuxt.js interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Hybrid Rendering with routeRules

Hybrid rendering is Nuxt 3's flagship feature. It allows defining different rendering rules per route, combining the best of SSR and SSG.

nuxt.config.tstypescript
// Advanced hybrid rendering configuration

export default defineNuxtConfig({
  routeRules: {
    // Marketing pages: pre-rendered and cached long-term
    '/': { prerender: true },
    '/pricing': { prerender: true },
    '/features/**': { prerender: true },

    // Blog: ISR (Incremental Static Regeneration)
    // Revalidation every hour
    '/blog/**': {
      isr: 3600,
      prerender: true
    },

    // Documentation: CDN cache with revalidation
    '/docs/**': {
      swr: 86400, // Stale-while-revalidate
      prerender: true
    },

    // E-commerce: SSR with short cache
    '/products/**': {
      ssr: true,
      cache: {
        maxAge: 60,
        staleMaxAge: 300
      }
    },

    // Cart and checkout: client-side only
    '/cart': { ssr: false },
    '/checkout/**': { ssr: false },

    // Dashboard: SPA mode
    '/dashboard/**': {
      ssr: false,
      // Disable prerendering
      prerender: false
    },

    // API routes: no cache by default
    '/api/**': {
      cors: true,
      headers: {
        'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE'
      }
    }
  }
})

This configuration illustrates a typical modern application architecture: public pages are optimized for SEO with SSG, while interactive sections use client rendering.

Performance Optimization with Data Caching

Beyond page caching, Nuxt 3 allows caching fetched data. This strategy reduces API load and improves response times.

server/api/articles/[slug].get.tstypescript
// API endpoint with data caching

import { getArticleBySlug } from '~/server/utils/articles'

export default defineCachedEventHandler(
  async (event) => {
    const slug = getRouterParam(event, 'slug')

    if (!slug) {
      throw createError({
        statusCode: 400,
        message: 'Missing slug'
      })
    }

    const article = await getArticleBySlug(slug)

    if (!article) {
      throw createError({
        statusCode: 404,
        message: 'Article not found'
      })
    }

    return article
  },
  {
    // Cache key based on slug
    getKey: (event) => `article-${getRouterParam(event, 'slug')}`,
    // Cache duration: 1 hour
    maxAge: 3600,
    // Stale-while-revalidate: serve stale cache during update
    staleMaxAge: 7200,
    // Tag-based invalidation
    tags: ['articles']
  }
)

To invalidate cache when content changes, Nuxt provides a tag system.

server/api/articles/[slug].put.tstypescript
// Article update with cache invalidation

import { updateArticle } from '~/server/utils/articles'

export default defineEventHandler(async (event) => {
  const slug = getRouterParam(event, 'slug')
  const body = await readBody(event)

  // Update the article
  const article = await updateArticle(slug, body)

  // Invalidate cache for this article
  await useStorage('cache').removeItem(`nitro:handlers:article-${slug}`)

  // Or tag-based invalidation (all articles)
  // await useStorage('cache').clear('articles')

  return article
})
Distributed Cache

In production with multiple instances, in-memory cache is insufficient. Configuring Redis or another distributed system via Nitro configuration is recommended to ensure consistency across instances.

SEO and Metadata Management

SSR enables SEO optimization by generating metadata server-side. Nuxt 3 offers several approaches for managing meta tags dynamically.

vue
<script setup lang="ts">
// pages/blog/[slug].vue
// Blog page with optimized SEO

const route = useRoute()

const { data: article } = await useFetch(`/api/articles/${route.params.slug}`)

// Dynamic SEO configuration based on article
useSeoMeta({
  title: article.value?.title,
  description: article.value?.excerpt,
  ogTitle: article.value?.title,
  ogDescription: article.value?.excerpt,
  ogImage: article.value?.coverImage,
  ogType: 'article',
  twitterCard: 'summary_large_image',
  twitterTitle: article.value?.title,
  twitterDescription: article.value?.excerpt,
  twitterImage: article.value?.coverImage
})

// Structured data for Google
useHead({
  script: [
    {
      type: 'application/ld+json',
      innerHTML: JSON.stringify({
        '@context': 'https://schema.org',
        '@type': 'Article',
        headline: article.value?.title,
        description: article.value?.excerpt,
        image: article.value?.coverImage,
        datePublished: article.value?.publishedAt,
        dateModified: article.value?.updatedAt,
        author: {
          '@type': 'Organization',
          name: 'SharpSkill'
        }
      })
    }
  ]
})
</script>

For static pages, metadata can be defined directly in the component.

vue
<script setup lang="ts">
// pages/about.vue
// Static page with SEO

definePageMeta({
  title: 'About'
})

useSeoMeta({
  title: 'About SharpSkill | Tech Interview Preparation',
  description: 'Discover SharpSkill, the tech interview preparation platform. Our mission: helping developers succeed in their technical interviews.',
  ogTitle: 'About SharpSkill',
  ogDescription: 'The tech interview preparation platform',
  ogImage: '/images/og-about.webp'
})
</script>

Deployment and Production Considerations

The deployment choice depends on the rendering mode used. Here are the main options and their configurations.

nuxt.config.tstypescript
// Configuration for different deployment environments

export default defineNuxtConfig({
  nitro: {
    // Preset based on target platform
    // preset: 'vercel', // Vercel
    // preset: 'netlify', // Netlify
    // preset: 'cloudflare-pages', // Cloudflare
    // preset: 'node-server', // Classic Node.js

    // Configuration for Node.js in production
    preset: 'node-server',

    // Response compression
    compressPublicAssets: true,

    // Cache storage configuration
    storage: {
      cache: {
        driver: 'redis',
        url: process.env.REDIS_URL
      }
    }
  },

  // Runtime environment variables
  runtimeConfig: {
    // Secrets (not exposed to client)
    apiSecret: process.env.API_SECRET,
    // Public configuration
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api'
    }
  }
})

For static deployment, the npm run generate command creates an .output/public folder ready to deploy on any static file host.

bash
# Static generation
npm run generate

# The .output/public content can be deployed to:
# - Vercel (automatic detection)
# - Netlify (automatic configuration)
# - GitHub Pages
# - S3 + CloudFront
# - Any CDN or static file server

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Conclusion

Nuxt 3 offers exceptional flexibility for rendering Vue.js applications. The choice between SSR, SSG, and hybrid rendering depends on each project's specific needs.

Key takeaways:

SSR: ideal for dynamic content requiring good SEO (e-commerce, news sites)

SSG: perfect for stable content (blogs, documentation, marketing sites)

Hybrid: the best approach for complex applications with varied needs

useFetch/useAsyncData: automatic hydration and cache management

routeRules: fine-grained configuration for each route's behavior

Caching: multiple strategies to optimize production performance

Combining hybrid rendering with a well-thought-out caching strategy enables building performant applications optimized for SEO while maintaining the interactivity of Single Page Applications.

Tags

#nuxt 3
#vue.js
#ssr
#static generation
#web performance

Share

Related articles