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 } } })
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() } })
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() } })
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>
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>
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 } })
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 }
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 }) } })
ISR vs Traditional Caching
Edge Cases and Considerations
- Cold Start Performance: First visitor after cache expiry experiences slower load
- Cache Invalidation: Manual invalidation requires webhook setup
- Memory Usage: File-based caching requires disk space management
- Deployment Strategy: Cache persists across deployments
Best Practices
-
Choose appropriate cache durations:
- News/updates: 5-15 minutes
- Blog posts: 1-6 hours
- Documentation: 1-24 hours
- Marketing pages: 1-7 days
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 }
- Monitor cache hit rates:
// Log cache performance setResponseHeader(event, 'x-cache-timestamp', Date.now().toString()) setResponseHeader(event, 'x-cache-ttl', '3600')
Deployment Considerations
Vercel
{ "functions": { "app/**": { "maxDuration": 30 } }, "isr": { "expiration": 3600 } }
Netlify
[build] command = "npm run build" publish = ".output/public" [[headers]] for = "/blog/*" [headers.values] Cache-Control = "public, s-maxage=3600, stale-while-revalidate=86400"
Cloudflare Pages
// nuxt.config.ts nitro: { preset: 'cloudflare-pages', cloudflare: { pages: { routes: { include: ['/*'], exclude: ['/api/*'] } } } }
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)