DEV Community

A0mineTV
A0mineTV

Posted on

Implementing Incremental Static Regeneration (ISR) in Nuxt 4: The Complete Guide

Incremental Static Regeneration (ISR) is a game-changing feature that combines the best of static generation and server-side rendering. With Nuxt 4, implementing ISR has become more streamlined and powerful than ever. In this comprehensive guide, I'll show you how to build a complete ISR-enabled application with real-world examples.

What is ISR and Why Should You Care?

ISR allows you to serve statically generated pages while updating them incrementally in the background. Think of it as having your cake and eating it too:

  • โšก Lightning-fast performance - Pages served from cache
  • ๐Ÿ”„ Always fresh content - Background regeneration keeps data current
  • ๐Ÿ“ˆ Infinite scalability - No server overload on traffic spikes
  • ๐ŸŽฏ SEO excellence - Pre-rendered content for search engines

The ISR Advantage Over Traditional Approaches

Approach Performance Freshness Scalability SEO
Static (SSG) โญโญโญโญโญ โŒ โญโญโญโญโญ โญโญโญโญโญ
Server-Side (SSR) โญโญ โญโญโญโญโญ โญโญ โญโญโญโญ
Client-Side (SPA) โญโญโญ โญโญโญโญโญ โญโญโญโญ โŒ
ISR โญโญโญโญโญ โญโญโญโญ โญโญโญโญโญ โญโญโญโญโญ

Setting Up Nuxt 4 for ISR

First, let's configure Nuxt 4 with the proper ISR setup:

// nuxt.config.ts
export default defineNuxtConfig({
  devtools: { enabled: true },
  pages: true,

  // Enable Nuxt 4 features
  future: { compatibilityVersion: 4 },

  experimental: {
    payloadExtraction: false
  },

  // Nitro configuration for ISR
  nitro: {
    preset: 'node-server',
    experimental: { wasm: true },
    // Stable cache directory
    storage: {
      cache: {
        driver: 'fs',
        base: './.data/cache'
      }
    }
  },

  // Route-based ISR configuration
  routeRules: {
    // Homepage can be SSR or prerendered
    '/': {},

    // Blog posts regenerate every hour
    '/blog': { isr: 300 },       // 5 minutes for index
    '/blog/**': { isr: 3600 },   // 1 hour for posts

    // Product catalog with frequent updates
    '/products': { isr: 300 },
    '/products/**': { isr: 300 }, // 5 minutes for products

    // API routes with CORS
    '/api/**': { cors: true }
  }
})
Enter fullscreen mode Exit fullscreen mode

Building a Real-World Example: Blog with ISR

Let's create a blog that demonstrates ISR in action. We'll build both the frontend pages and API endpoints.

1. API Endpoint for Blog Posts

// server/api/blog/index.get.ts
const blogPosts = [
  {
    id: 1,
    slug: 'nuxt-isr-guide',
    title: 'Complete Guide to ISR with Nuxt 4',
    excerpt: 'Learn how to implement ISR in your Nuxt applications',
    content: `
      <h2>Introduction to ISR</h2>
      <p>Incremental Static Regeneration (ISR) combines the benefits of static generation with dynamic content updates.</p>

      <h2>Key Benefits</h2>
      <ul>
        <li>Optimal performance</li>
        <li>Always up-to-date content</li>
        <li>Reduced server load</li>
      </ul>

      <p>Generated at: ${new Date().toLocaleTimeString()}</p>
    `,
    publishedAt: '2024-01-15T10:00:00Z',
    author: 'Tech Writer'
  },
  // ... more posts
]

export default defineEventHandler(async (event) => {
  // Simulate API delay
  await new Promise(resolve => setTimeout(resolve, 200))

  // Cache headers for ISR
  setResponseHeader(event, 'cache-control', 'public, s-maxage=300, stale-while-revalidate=600')

  return {
    posts: blogPosts.map(post => ({
      id: post.id,
      slug: post.slug,
      title: post.title,
      excerpt: post.excerpt,
      publishedAt: post.publishedAt,
      updatedAt: new Date().toISOString()
    })),
    generatedAt: new Date().toISOString()
  }
})
Enter fullscreen mode Exit fullscreen mode

2. Individual Blog Post API

// server/api/blog/[slug].get.ts
export default defineEventHandler(async (event) => {
  const slug = getRouterParam(event, 'slug')

  // Simulate database lookup
  await new Promise(resolve => setTimeout(resolve, 300))

  const post = blogPosts.find(p => p.slug === slug)

  if (!post) {
    throw createError({
      statusCode: 404,
      statusMessage: 'Post not found'
    })
  }

  // ISR cache headers (1 hour with stale-while-revalidate)
  setResponseHeader(event, 'cache-control', 'public, s-maxage=3600, stale-while-revalidate=86400')
  setResponseHeader(event, 'x-cache', 'MISS')

  return {
    ...post,
    updatedAt: new Date().toISOString(),
    generatedAt: new Date().toISOString()
  }
})
Enter fullscreen mode Exit fullscreen mode

3. Blog Index Page with ISR Debug Info

<!-- pages/blog/index.vue -->
<template>
  <div class="blog-index">
    <h1>Blog with ISR</h1>
    <p class="subtitle">Demonstrating Incremental Static Regeneration</p>

    <div class="posts-list">
      <article
        v-for="post in data.posts"
        :key="post.id"
        class="post-card"
      >
        <NuxtLink :to="`/blog/${post.slug}`" class="post-link">
          <h2>{{ post.title }}</h2>
          <p class="excerpt">{{ post.excerpt }}</p>
          <div class="meta">
            <span>Published {{ formatDate(post.publishedAt) }}</span>
            <span>Updated: {{ formatDate(post.updatedAt) }}</span>
          </div>
        </NuxtLink>
      </article>
    </div>

    <!-- ISR Debug Information -->
    <div class="isr-info">
      <h3>๐Ÿš€ ISR Information</h3>
      <p><strong>Page generated at:</strong> {{ formatDateTime(data.generatedAt) }}</p>
      <p><strong>Cache duration:</strong> 5 minutes (300s)</p>
      <p><strong>Revalidation:</strong> 10 minutes in background</p>
      <button @click="refresh()" class="refresh-btn">
        Refresh Page
      </button>
    </div>
  </div>
</template>

<script setup>
// Fetch data with ISR
const { data, refresh } = await $fetch('/api/blog')

// SEO meta tags
useSeoMeta({
  title: 'ISR Blog - Nuxt 4',
  description: 'Demonstrating Incremental Static Regeneration with Nuxt 4',
  ogTitle: 'Blog with ISR',
  ogDescription: 'See ISR in action'
})

function formatDate(dateString) {
  return new Date(dateString).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}

function formatDateTime(dateString) {
  return new Date(dateString).toLocaleString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit'
  })
}
</script>

<style scoped>
.blog-index {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.posts-list {
  margin-bottom: 3rem;
}

.post-card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  margin-bottom: 1.5rem;
  transition: transform 0.2s, box-shadow 0.2s;
}

.post-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}

.post-link {
  display: block;
  padding: 1.5rem;
  text-decoration: none;
  color: inherit;
}

.isr-info {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  padding: 2rem;
  border-radius: 12px;
  text-align: center;
}

.refresh-btn {
  background: rgba(255,255,255,0.2);
  color: white;
  border: 2px solid rgba(255,255,255,0.3);
  padding: 0.75rem 1.5rem;
  border-radius: 25px;
  cursor: pointer;
  transition: all 0.3s;
}

.refresh-btn:hover {
  background: rgba(255,255,255,0.3);
  transform: translateY(-1px);
}
</style>
Enter fullscreen mode Exit fullscreen mode

4. Individual Blog Post Page

<!-- pages/blog/[slug].vue -->
<template>
  <div class="blog-post">
    <article>
      <header class="post-header">
        <h1>{{ post.title }}</h1>
        <div class="post-meta">
          <p>By {{ post.author }}</p>
          <p>Published {{ formatDate(post.publishedAt) }}</p>
          <p>Updated: {{ formatDate(post.updatedAt) }}</p>
        </div>
      </header>

      <div class="post-content" v-html="post.content"></div>

      <footer class="post-footer">
        <NuxtLink to="/blog" class="back-link">
          โ† Back to blog
        </NuxtLink>
      </footer>
    </article>

    <!-- ISR Debug Panel -->
    <div class="isr-debug">
      <h3>๐Ÿ”ง ISR Debug Panel</h3>
      <div class="debug-grid">
        <div class="debug-item">
          <strong>Page generated:</strong>
          {{ formatDateTime(post.generatedAt) }}
        </div>
        <div class="debug-item">
          <strong>ISR cache:</strong>
          1 hour (3600s)
        </div>
        <div class="debug-item">
          <strong>Stale While Revalidate:</strong>
          24 hours
        </div>
        <div class="debug-item">
          <strong>Slug:</strong>
          {{ $route.params.slug }}
        </div>
      </div>

      <button @click="reloadPage" class="reload-btn">
        ๐Ÿ”„ Reload Page
      </button>
    </div>
  </div>
</template>

<script setup>
const route = useRoute()
const slug = route.params.slug

// Fetch post data with ISR
const { data: post } = await $fetch(`/api/blog/${slug}`)

// Handle 404
if (!post) {
  throw createError({
    statusCode: 404,
    statusMessage: 'Post not found'
  })
}

// Dynamic SEO meta tags
useSeoMeta({
  title: post.title,
  description: post.excerpt,
  ogTitle: post.title,
  ogDescription: post.excerpt,
  ogType: 'article'
})

function formatDate(dateString) {
  return new Date(dateString).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}

function formatDateTime(dateString) {
  return new Date(dateString).toLocaleString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit'
  })
}

function reloadPage() {
  window.location.reload()
}
</script>
Enter fullscreen mode Exit fullscreen mode

Advanced ISR Patterns

1. Webhook-Based Revalidation

Set up webhooks to trigger revalidation when content changes:

// server/api/webhook/revalidate.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  // Verify webhook signature
  const isValid = verifyWebhookSignature(body, getHeader(event, 'x-signature'))

  if (!isValid) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid signature'
    })
  }

  // Clear cache for specific routes
  if (body.type === 'blog.updated') {
    const slug = body.data.slug

    // Clear the specific blog post cache
    await clearNuxtData(`blog-${slug}`)

    // Trigger regeneration
    await fetch(`${getBaseURL()}/blog/${slug}`)

    return { revalidated: true, slug }
  }

  return { revalidated: false }
})
Enter fullscreen mode Exit fullscreen mode

2. Category-Based ISR

Different cache durations for different content types:

// nuxt.config.ts
routeRules: {
  // News articles need frequent updates
  '/news/**': { isr: 300 },    // 5 minutes

  // Documentation can be cached longer
  '/docs/**': { isr: 3600 },   // 1 hour

  // Marketing pages rarely change
  '/features/**': { isr: 86400 }, // 24 hours

  // User-generated content
  '/profile/**': { isr: 60 },  // 1 minute
}
Enter fullscreen mode Exit fullscreen mode

3. Performance Monitoring

Track ISR effectiveness:

// plugins/isr-analytics.client.ts
export default defineNuxtPlugin(() => {
  // Track page generation time
  const performance = window.performance
  const navigationStart = performance.timing?.navigationStart
  const loadComplete = performance.timing?.loadEventEnd

  if (navigationStart && loadComplete) {
    const loadTime = loadComplete - navigationStart

    // Send to your analytics service
    analytics.track('page_load', {
      loadTime,
      fromCache: document.querySelector('meta[name="x-cache"]')?.getAttribute('content'),
      route: useRoute().path
    })
  }
})
Enter fullscreen mode Exit fullscreen mode

ISR vs Traditional Caching

Edge Cases and Considerations

  1. Cold Start Performance: First visitor after cache expiry experiences slower load
  2. Cache Invalidation: Manual invalidation requires webhook setup
  3. Memory Usage: File-based caching requires disk space management
  4. Deployment Strategy: Cache persists across deployments

Best Practices

  1. Choose appropriate cache durations:

    • News/updates: 5-15 minutes
    • Blog posts: 1-6 hours
    • Documentation: 1-24 hours
    • Marketing pages: 1-7 days
  2. Implement proper error handling:

   // Always have fallbacks
   const { data, error } = await $fetch('/api/posts').catch(() => ({ data: null, error: true }))

   if (error) {
     // Show cached version or error state
   }
Enter fullscreen mode Exit fullscreen mode
  1. Monitor cache hit rates:
   // Log cache performance
   setResponseHeader(event, 'x-cache-timestamp', Date.now().toString())
   setResponseHeader(event, 'x-cache-ttl', '3600')
Enter fullscreen mode Exit fullscreen mode

Deployment Considerations

Vercel

{
  "functions": {
    "app/**": {
      "maxDuration": 30
    }
  },
  "isr": {
    "expiration": 3600
  }
}
Enter fullscreen mode Exit fullscreen mode

Netlify

[build]
  command = "npm run build"
  publish = ".output/public"

[[headers]]
  for = "/blog/*"
  [headers.values]
    Cache-Control = "public, s-maxage=3600, stale-while-revalidate=86400"
Enter fullscreen mode Exit fullscreen mode

Cloudflare Pages

// nuxt.config.ts
nitro: {
  preset: 'cloudflare-pages',
  cloudflare: {
    pages: {
      routes: {
        include: ['/*'],
        exclude: ['/api/*']
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Performance Comparison

I tested the same content with different rendering strategies:

Metric SSR SSG ISR
TTFB 800ms 45ms 50ms
FCP 1200ms 200ms 220ms
LCP 1500ms 400ms 450ms
Content Freshness Real-time Build-time Near real-time
Server Load High None Low

Conclusion

ISR in Nuxt 4 provides the perfect balance between performance and freshness. It's particularly powerful for:

  • Content-heavy sites (blogs, news, documentation)
  • E-commerce catalogs with frequent stock updates
  • Marketing sites with occasional content changes
  • User dashboards with semi-static data

The configuration is straightforward, the performance benefits are significant, and the developer experience is excellent. If you're building modern web applications that need both speed and fresh content, ISR should be your go-to strategy.

Top comments (0)