Skip to content

Next.js Caching Strategies ​

Optimize your Next.js application performance with PGRestify's comprehensive caching strategies across different rendering modes and data fetching patterns.

Overview ​

Next.js provides multiple caching layers that PGRestify integrates with:

  • Request Memoization: Deduplicates requests during a single render
  • Data Cache: Persistent cache across requests (App Router)
  • Full Route Cache: Caches entire route output
  • Router Cache: Client-side navigation cache
  • PGRestify Query Cache: Application-level query caching

Caching Fundamentals ​

Next.js Cache Hierarchy ​

Client Request
    ↓
Router Cache (Client)
    ↓
Full Route Cache (Server)
    ↓
Data Cache (Server)
    ↓
Request Memoization (Server)
    ↓
Database Query

App Router Caching ​

Data Cache Integration ​

typescript
// lib/cached-client.ts
import { createNextJSClient } from '@webcoded/pgrestify/nextjs'
import { unstable_cache } from 'next/cache'

export const client = createNextJSClient({
  url: process.env.POSTGREST_URL!,
  cache: {
    enabled: true,
    defaultTTL: 3600 // 1 hour default
  }
})

// Create cached query function
export const getCachedUsers = unstable_cache(
  async () => {
    const result = await client
      .from('users')
      .select('*')
      .execute()
    return result.data
  },
  ['users'], // Cache key
  {
    revalidate: 3600, // Revalidate after 1 hour
    tags: ['users'] // Cache tags for invalidation
  }
)

Force Cache vs No Store ​

typescript
// app/products/page.tsx

// Static rendering with cache
async function getStaticProducts() {
  const result = await client
    .from('products')
    .select('*')
    .execute({
      cache: 'force-cache', // Cache indefinitely
      next: {
        revalidate: 86400, // Revalidate daily
        tags: ['products']
      }
    })
  
  return result.data
}

// Dynamic rendering without cache
async function getDynamicInventory() {
  const result = await client
    .from('inventory')
    .select('*')
    .execute({
      cache: 'no-store' // Always fetch fresh data
    })
  
  return result.data
}

Time-based Revalidation ​

typescript
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Page-level revalidation

export default async function BlogPost({ params }) {
  const { slug } = await params
  
  // This query will be cached for 1 hour
  const result = await client
    .from('posts')
    .select('*, author:users(*), comments(*)')
    .eq('slug', slug)
    .single()
    .execute()
  
  return <Article post={result.data} />
}

On-Demand Revalidation ​

typescript
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const { type, value } = await request.json()
  
  try {
    if (type === 'tag') {
      // Revalidate all cached data with this tag
      revalidateTag(value)
    } else if (type === 'path') {
      // Revalidate specific path
      revalidatePath(value)
    }
    
    return NextResponse.json({ revalidated: true })
  } catch (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }
}

Pages Router Caching ​

Static Generation (SSG) ​

typescript
// pages/products.tsx
import { createGetStaticProps } from '@webcoded/pgrestify/nextjs'

export const getStaticProps = createGetStaticProps(
  async (client) => {
    const result = await client
      .from('products')
      .select('*')
      .eq('active', true)
      .execute()
    
    return {
      products: result.data || []
    }
  },
  {
    revalidate: 3600 // ISR: Revalidate every hour
  }
)

Server-Side Rendering (SSR) with Cache ​

typescript
// pages/dashboard.tsx
import { createGetServerSideProps } from '@webcoded/pgrestify/nextjs'

export const getServerSideProps = createGetServerSideProps(
  async (client, context) => {
    const result = await client
      .from('dashboard_stats')
      .select('*')
      .single()
      .execute()
    
    // Set cache headers
    context.res.setHeader(
      'Cache-Control',
      'public, s-maxage=60, stale-while-revalidate=120'
    )
    
    return {
      stats: result.data
    }
  }
)

Query-Level Caching ​

PGRestify Query Cache ​

typescript
// lib/query-cache.ts
import { MemoryQueryCache } from '@webcoded/pgrestify'

// Create a shared cache instance
export const queryCache = new MemoryQueryCache({
  maxSize: 100, // Maximum cached queries
  ttl: 60 * 1000, // 1 minute default TTL
  staleWhileRevalidate: true
})

// Use with client
const client = createNextJSClient({
  url: process.env.POSTGREST_URL!,
  cache: queryCache
})

Cache Key Generation ​

typescript
// lib/cache-keys.ts
import { CacheKeyBuilder } from '@webcoded/pgrestify'

export function getUserCacheKey(userId: string, filters?: any) {
  return new CacheKeyBuilder('users')
    .withId(userId)
    .withFilters(filters)
    .build()
}

// Usage
const cacheKey = getUserCacheKey('123', { active: true })
const result = await client
  .from('users')
  .select('*')
  .eq('id', '123')
  .eq('active', true)
  .execute({ cacheKey })

Conditional Caching ​

typescript
// app/api/data/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const useCache = searchParams.get('cache') !== 'false'
  
  const result = await client
    .from('data')
    .select('*')
    .execute({
      cache: useCache ? {
        ttl: 300, // 5 minutes
        key: `data-${searchParams.toString()}`
      } : false
    })
  
  return Response.json(result.data)
}

Client-Side Caching ​

React Query Integration ​

typescript
// hooks/useProducts.ts
import { useQuery, useQueryClient } from '@webcoded/pgrestify/react'
import { client } from '../lib/client'

export function useProducts(category?: string) {
  return useQuery({
    queryKey: ['products', category],
    queryFn: async () => {
      const query = client.from('products').select('*')
      if (category) {
        query.eq('category', category)
      }
      const result = await query.execute()
      return result.data
    },
    staleTime: 5 * 60 * 1000, // 5 minutes
    gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
  })
}

// Invalidate cache
export function useInvalidateProducts() {
  const queryClient = useQueryClient()
  
  return () => {
    queryClient.invalidateQueries({ queryKey: ['products'] })
  }
}

SWR Integration ​

typescript
// hooks/useSWRProducts.ts
import useSWR from 'swr'
import { client } from '../lib/client'

const fetcher = async (table: string, filters?: any) => {
  const query = client.from(table).select('*')
  
  if (filters) {
    Object.entries(filters).forEach(([key, value]) => {
      query.eq(key, value)
    })
  }
  
  const result = await query.execute()
  return result.data
}

export function useProducts(category?: string) {
  const { data, error, mutate } = useSWR(
    ['products', { category }],
    ([table, filters]) => fetcher(table, filters),
    {
      revalidateOnFocus: false,
      revalidateOnReconnect: false,
      refreshInterval: 60000 // Refresh every minute
    }
  )
  
  return {
    products: data,
    isLoading: !error && !data,
    isError: error,
    mutate
  }
}

Edge Caching ​

Edge Runtime Cache ​

typescript
// app/api/edge-cached/route.ts
export const runtime = 'edge'

const cache = new Map()

export async function GET(request: Request) {
  const url = new URL(request.url)
  const cacheKey = url.pathname + url.search
  
  // Check edge cache
  const cached = cache.get(cacheKey)
  if (cached && cached.expires > Date.now()) {
    return Response.json(cached.data)
  }
  
  // Fetch fresh data
  const result = await client
    .from('data')
    .select('*')
    .execute()
  
  // Update cache
  cache.set(cacheKey, {
    data: result.data,
    expires: Date.now() + 60000 // 1 minute
  })
  
  return Response.json(result.data)
}

CDN Cache Headers ​

typescript
// app/api/cdn-cached/route.ts
export async function GET(request: Request) {
  const result = await client
    .from('static_content')
    .select('*')
    .execute()
  
  return Response.json(result.data, {
    headers: {
      'Cache-Control': 'public, max-age=31536000, immutable',
      'CDN-Cache-Control': 'max-age=86400',
      'Surrogate-Control': 'max-age=31536000',
      'Surrogate-Key': 'static-content'
    }
  })
}

Advanced Caching Patterns ​

Stale-While-Revalidate ​

typescript
// lib/swr-cache.ts
class SWRCache {
  private cache = new Map()
  private revalidating = new Map()
  
  async get(key: string, fetcher: () => Promise<any>, options = {}) {
    const { ttl = 60000, staleTime = 300000 } = options
    const cached = this.cache.get(key)
    const now = Date.now()
    
    // Return cached if fresh
    if (cached && cached.expires > now) {
      return cached.data
    }
    
    // Return stale and revalidate in background
    if (cached && cached.staleExpires > now) {
      if (!this.revalidating.has(key)) {
        this.revalidating.set(key, true)
        fetcher().then(data => {
          this.cache.set(key, {
            data,
            expires: now + ttl,
            staleExpires: now + staleTime
          })
          this.revalidating.delete(key)
        })
      }
      return cached.data
    }
    
    // Fetch fresh data
    const data = await fetcher()
    this.cache.set(key, {
      data,
      expires: now + ttl,
      staleExpires: now + staleTime
    })
    
    return data
  }
  
  invalidate(pattern?: string) {
    if (pattern) {
      for (const key of this.cache.keys()) {
        if (key.includes(pattern)) {
          this.cache.delete(key)
        }
      }
    } else {
      this.cache.clear()
    }
  }
}

export const swrCache = new SWRCache()

Hierarchical Cache Invalidation ​

typescript
// lib/cache-invalidation.ts
import { revalidateTag } from 'next/cache'

class CacheInvalidator {
  private dependencies = new Map<string, string[]>()
  
  constructor() {
    // Define cache dependencies
    this.dependencies.set('users', ['posts', 'comments'])
    this.dependencies.set('posts', ['comments'])
  }
  
  async invalidate(tag: string) {
    // Invalidate the tag itself
    revalidateTag(tag)
    
    // Invalidate dependent tags
    const deps = this.dependencies.get(tag) || []
    for (const dep of deps) {
      revalidateTag(dep)
    }
  }
  
  async invalidateUser(userId: string) {
    revalidateTag(`user-${userId}`)
    revalidateTag(`user-posts-${userId}`)
    revalidateTag(`user-comments-${userId}`)
  }
}

export const cacheInvalidator = new CacheInvalidator()

Partial Cache Updates ​

typescript
// lib/partial-cache.ts
export async function updateCachedItem<T>(
  cacheKey: string,
  itemId: string | number,
  updates: Partial<T>
) {
  const cached = queryCache.get(cacheKey)
  
  if (cached && Array.isArray(cached)) {
    const updatedItems = cached.map(item =>
      item.id === itemId ? { ...item, ...updates } : item
    )
    queryCache.set(cacheKey, updatedItems)
  }
}

// Usage after mutation
const result = await client
  .from('posts')
  .update({ title: 'New Title' })
  .eq('id', postId)
  .execute()

if (result.data) {
  updateCachedItem('posts', postId, result.data)
}

Cache Warming ​

Preload Critical Data ​

typescript
// app/layout.tsx
import { headers } from 'next/headers'

async function preloadData() {
  // Preload critical data in parallel
  await Promise.all([
    client.from('navigation').select('*').execute(),
    client.from('user_preferences').select('*').execute(),
    client.from('feature_flags').select('*').execute()
  ])
}

export default async function RootLayout({ children }) {
  // Warm cache on first request
  await preloadData()
  
  return (
    <html>
      <body>{children}</body>
    </html>
  )
}

Background Revalidation ​

typescript
// lib/background-revalidation.ts
import { unstable_noStore as noStore } from 'next/cache'

export function startBackgroundRevalidation() {
  if (typeof window === 'undefined') {
    // Server-side only
    setInterval(async () => {
      noStore() // Opt out of caching
      
      // Revalidate critical data
      await Promise.all([
        revalidateTag('products'),
        revalidateTag('categories'),
        revalidatePath('/'),
      ])
    }, 5 * 60 * 1000) // Every 5 minutes
  }
}

Performance Monitoring ​

Cache Hit Rate Tracking ​

typescript
// lib/cache-metrics.ts
class CacheMetrics {
  private hits = 0
  private misses = 0
  
  recordHit() {
    this.hits++
  }
  
  recordMiss() {
    this.misses++
  }
  
  getHitRate() {
    const total = this.hits + this.misses
    return total > 0 ? (this.hits / total) * 100 : 0
  }
  
  reset() {
    this.hits = 0
    this.misses = 0
  }
  
  getMetrics() {
    return {
      hits: this.hits,
      misses: this.misses,
      hitRate: this.getHitRate(),
      total: this.hits + this.misses
    }
  }
}

export const cacheMetrics = new CacheMetrics()

Performance Headers ​

typescript
// middleware.ts
export function middleware(request: NextRequest) {
  const response = NextResponse.next()
  
  // Add cache performance headers
  response.headers.set('X-Cache-Status', 'HIT') // or MISS
  response.headers.set('X-Cache-TTL', '3600')
  response.headers.set('X-Cache-Key', request.url)
  
  return response
}

Cache Configuration ​

Environment-based Caching ​

typescript
// lib/cache-config.ts
const isDevelopment = process.env.NODE_ENV === 'development'
const isProduction = process.env.NODE_ENV === 'production'

export const cacheConfig = {
  // Disable caching in development for easier debugging
  enabled: isProduction,
  
  // Shorter TTLs in development
  defaultTTL: isDevelopment ? 10 : 3600,
  
  // More aggressive caching in production
  staleWhileRevalidate: isProduction,
  
  // Cache size limits
  maxSize: isProduction ? 1000 : 100,
  
  // Cache key prefix by environment
  keyPrefix: process.env.VERCEL_ENV || 'local'
}

Best Practices ​

1. Choose the Right Cache Strategy ​

  • Static content: force-cache with long TTL
  • User-specific: no-store or short TTL
  • Frequently updated: Stale-while-revalidate

2. Implement Cache Invalidation ​

  • Use tags for granular invalidation
  • Invalidate related data together
  • Implement webhook handlers for external updates

3. Monitor Cache Performance ​

  • Track hit rates
  • Monitor cache size
  • Set up alerts for cache misses

4. Optimize Cache Keys ​

  • Include relevant parameters
  • Normalize query parameters
  • Use consistent key patterns

5. Handle Cache Failures Gracefully ​

  • Implement fallbacks
  • Log cache errors
  • Degrade gracefully to database queries

Troubleshooting ​

Common Issues ​

1. Cache Not Updating

typescript
// Force revalidation
revalidatePath('/path')
revalidateTag('tag')

2. Memory Leaks

typescript
// Implement cache size limits
const cache = new MemoryQueryCache({
  maxSize: 100,
  onEvict: (key, value) => {
    console.log(`Evicted cache entry: ${key}`)
  }
})

3. Stale Data

typescript
// Use shorter TTLs or implement real-time updates
const result = await client.from('data').select('*').execute({
  cache: { ttl: 60 } // 1 minute TTL
})

Next Steps ​

Released under the MIT License.