Logo

Migrating from SSR to ISG: A Production Performance Story

9 min read
Next.jssupabase

Table of Contents

Intro

Every millisecond of latency matters, even for small applications. This is the story of migrating a recipe sharing platform from Server-Side Rendering (SSR) to Incremental Static Regeneration (ISG), achieving 90% latency reduction while building a foundation that scales.

The Problem: Death by a Thousand Queries

Our recipe platform started with a classic SSR setup - Next.js app with Supabase backend, fetching fresh data on every request:

1// The innocent-looking code that was destroying performance
2export default async function HomePage() {
3 const recipes = await getRecipesFromSupabase() // 200-400ms per request
4 return <RecipeList recipes={recipes} />
5}

Even with modest traffic, the problems were obvious:

The fundamental issue? Recipe data changed maybe 10-20 times per day, but we were fetching it on every single request. Classic over-fetching problem.

Understanding the Rendering Spectrum

Before diving into the solution, let's clarify the rendering strategies in Next.js from a performance engineer's perspective:

Static Site Generation (SSG)

1export const dynamic = 'force-static'

Server-Side Rendering (SSR)

1// Default behavior for async components in App Router

Incremental Static Regeneration (ISG)

1export const revalidate = 3600 // Time-based
2// Or on-demand via revalidatePath()

The Architecture Decision

ISG was perfect for our recipe platform because:

  1. Content velocity: Recipes update occasionally, but are read constantly
  2. Consistency requirements: Users don't need real-time recipe updates
  3. Performance goals: Sub-100ms response times globally
  4. Future scaling: Build infrastructure that scales without linear cost increase

Implementation: The Devil in the Details

Step 1: Cache Layer with Revalidation Tags

First, we wrapped our data fetching functions with Next.js's cache layer:

1import { unstable_cache } from 'next/cache'
2
3export const getRecipesFromSupabase = unstable_cache(
4 async (): Promise<Recipe[]> => {
5 const supabase = getSupabaseClient()
6 const { data, error } = await supabase
7 .from('recipes')
8 .select('*')
9 .eq('is_public', true)
10 .order('created_at', { ascending: false })
11
12 if (error) throw error
13 return objectToCamel(data)
14 },
15 ['recipes-list'], // Cache key
16 {
17 tags: ['recipes', 'recipes-list'], // Revalidation tags
18 revalidate: 3600, // Fallback: 1 hour
19 },
20)

The tags are crucial - they allow surgical cache invalidation. When a single recipe updates, we can invalidate just that recipe's page while keeping the rest cached.

Step 2: Page-Level Configuration

1// app/page.tsx
2export const revalidate = 3600 // Fallback revalidation
3
4// app/recipes/[id]/page.tsx
5export const revalidate = 3600
6export const dynamicParams = true // Generate pages on-demand
7
8export async function generateStaticParams() {
9 const recipes = await getRecipesFromSupabase()
10 // Pre-build only the 100 most popular recipes
11 return recipes.slice(0, 100).map((recipe) => ({
12 id: recipe.id,
13 }))
14}

Key decision: We only pre-generate the top 100 recipes at build time. The rest generate on first request. This keeps build times under 2 minutes while ensuring hot paths are always fast.

Step 3: On-Demand Revalidation via Webhooks

Here's where it gets interesting. Instead of time-based revalidation, we trigger updates exactly when data changes:

1// app/api/revalidate/route.ts
2export async function POST(request: NextRequest) {
3 const webhookSecret = request.headers.get('x-webhook-secret')
4
5 // Validate webhook authenticity
6 if (webhookSecret !== process.env.SUPABASE_WEBHOOK_SECRET) {
7 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
8 }
9
10 const payload = await request.json()
11
12 switch (payload.table) {
13 case 'recipes':
14 // Surgical revalidation based on operation type
15 revalidatePath('/') // Update home page
16
17 if (payload.record?.id || payload.old_record?.id) {
18 const recipeId = payload.record?.id || payload.old_record?.id
19 revalidatePath(`/recipes/${recipeId}`) // Specific recipe
20 }
21
22 revalidateTag('recipes') // All recipe-tagged caches
23 break
24
25 case 'bookmarks':
26 revalidateTag('bookmarks')
27 break
28 }
29
30 return NextResponse.json({ revalidated: true })
31}

Step 4: Supabase Webhook Configuration

The critical piece - configuring Supabase to notify our app of changes:

1-- Supabase webhook configuration
2CREATE TRIGGER recipe_changes
3AFTER INSERT OR UPDATE OR DELETE ON recipes
4FOR EACH ROW EXECUTE FUNCTION supabase_functions.http_request(
5 'https://your-app.vercel.app/api/revalidate',
6 'POST',
7 '{"Content-Type":"application/json","x-webhook-secret":"${WEBHOOK_SECRET}"}',
8 '{}',
9 '1000'
10);

Step 5: Client-Side Optimization

Even with ISG, we optimized the client experience by filtering cached data client-side rather than making API calls:

1// components/RecipeList.tsx
2const filterRecipesClientSide = useCallback(
3 (recipesToFilter: Recipe[], filters: RecipeFilters): Recipe[] => {
4 let filtered = [...recipesToFilter]
5
6 // All filtering happens in-memory, no API calls
7 if (filters.maxCookingTime) {
8 filtered = filtered.filter(
9 (recipe) => (recipe.cookTime || 0) <= filters.maxCookingTime,
10 )
11 }
12
13 if (filters.tag) {
14 filtered = filtered.filter((recipe) =>
15 recipe.tags?.includes(decodeURI(filters.tag)),
16 )
17 }
18
19 return filtered
20 },
21 [],
22)

This means search and filtering are instant - no loading states, no network latency.

Production Challenges and Solutions

Challenge 1: Webhook Reliability

Webhooks can fail. Network issues, deployment downtime, or rate limits can cause missed updates. Our solution:

  1. Fallback revalidation: Every page has revalidate: 3600 as a safety net
  2. Webhook retry logic: Supabase retries failed webhooks with exponential backoff
  3. Health monitoring: Alert on webhook failures > 1% threshold

Challenge 2: Cache Stampede

When a popular page expires, multiple concurrent requests might trigger regeneration. Next.js handles this with request coalescing, but we added:

1// Stale-while-revalidate pattern
2export const revalidate = 3600
3export const runtime = 'nodejs' // Not edge - need full Node.js for Supabase client

Challenge 3: Development vs Production Parity

ISG behaves differently in development (always dynamic) vs production (cached). We solved this with:

  1. Preview deployments: Every PR gets a Vercel preview with production-like caching
  2. Local webhook testing: Using ngrok to test Supabase webhooks locally
  3. Cache headers debugging: Custom middleware to log cache status
1// middleware.ts
2export function middleware(request: NextRequest) {
3 const response = NextResponse.next()
4
5 // Add cache debugging headers in development
6 if (process.env.NODE_ENV === 'development') {
7 response.headers.set(
8 'X-Cache-Status',
9 response.headers.get('x-vercel-cache') || 'MISS',
10 )
11 }
12
13 return response
14}

The Results: Numbers Don't Lie

After migrating to ISG with on-demand revalidation:

Performance Metrics

Infrastructure Metrics

Developer Experience

When ISG Makes Sense (And When It Doesn't)

ISG is perfect when:

ISG is wrong when:

Lessons Learned

  1. Measure everything: Even with low traffic, p99 latency reveals the true user experience. Don't just look at averages.

  2. Cache invalidation is still hard: Even with ISG, you need a clear mental model of what gets cached and when it invalidates.

  3. Webhooks need monitoring: They're critical path now. Treat them like any other production service.

  4. Client-side filtering is free: Once data is in the browser, filter it there. Don't make another round trip.

  5. Partial pre-generation is powerful: You don't need to generate 10,000 pages at build time. Generate the hot path, let the rest build on-demand.

Implementation Checklist

If you're considering ISG for your Next.js application:

Conclusion

Migrating from SSR to ISG isn't just about following a tutorial - it's about understanding your application's data access patterns, user expectations, and infrastructure constraints. For our recipe platform, ISG delivered dramatic improvements in performance and cost while maintaining a good developer experience.

The key insight? Not all dynamic content needs to be dynamically rendered. If your data changes infrequently but is read constantly, ISG with on-demand revalidation might be your secret weapon for scaling without breaking the bank.

Remember: The best cache is the one you don't have to think about. With ISG and webhooks, we achieved exactly that - automatic, efficient caching that updates precisely when needed.

Resources

Related Articles