DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Upstash Redis + Next.js: The Complete Guide (2026)

Redis is fast. But self-hosting Redis on a serverless stack is a nightmare — cold starts, connection pool exhaustion, and managing a persistent server that your serverless functions keep hammering. Upstash solves this with an HTTP-based Redis API that scales to zero, charges per request, and works natively with Next.js App Router.

This guide covers the patterns that actually matter in production: cache-aside with proper TTLs, SWR (stale-while-revalidate), session storage, and pub/sub. Real code, real trade-offs.

Read the full article with all code examples at stacknotice.com

Why Upstash Over a Traditional Redis Instance

Standard Redis uses persistent TCP connections. Serverless functions don't maintain persistent connections — every invocation potentially opens a new one. At scale, you hit ECONNREFUSED or max connection errors that are annoying to debug and expensive to fix.

Upstash's @upstash/redis client talks over HTTP/REST. No connection pool, no connection limit headaches. Each request is stateless. This is exactly what Next.js Server Components and Route Handlers need.

Other advantages:

  • Pay per request — a cache that never gets hit costs $0
  • Global replication — low latency from any Vercel edge region
  • Native Edge Runtime support — works in Next.js middleware
  • Free tier — 10,000 commands/day, no credit card needed

Setup

npm install @upstash/redis
Enter fullscreen mode Exit fullscreen mode
// lib/redis.ts
import { Redis } from '@upstash/redis'

export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
Enter fullscreen mode Exit fullscreen mode

Pattern 1: Cache-Aside

// lib/cache.ts
import { redis } from './redis'

export async function withCache<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: { ttl?: number; prefix?: string } = {}
): Promise<T> {
  const { ttl = 300, prefix = 'cache' } = options
  const cacheKey = `${prefix}:${key}`

  const cached = await redis.get<T>(cacheKey)
  if (cached !== null) return cached

  const data = await fetcher()
  await redis.setex(cacheKey, ttl, JSON.stringify(data))
  return data
}
Enter fullscreen mode Exit fullscreen mode

Usage in a Server Component:

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await withCache(
    'products:all',
    () => db.query.products.findMany({ where: eq(products.active, true) }),
    { ttl: 60 * 5 }
  )
  return <ProductList products={products} />
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Key Namespacing

// lib/cache-keys.ts
export const CacheKeys = {
  userProfile: (userId: string) => `user:${userId}:profile`,
  products: () => 'products:all',
  productById: (id: string) => `product:${id}`,
  pricingPlans: () => 'pricing:plans',
}

// Deterministic invalidation
async function invalidateUserCache(userId: string) {
  await redis.del(CacheKeys.userProfile(userId))
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Stale-While-Revalidate

SWR solves the thundering herd problem — serves stale data immediately while refreshing in the background:

export async function withSWRCache<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: { freshTtl: number; staleTtl: number }
): Promise<T> {
  const entry = await redis.get<{ data: T; cachedAt: number }>(key)

  if (entry !== null) {
    const age = (Date.now() - entry.cachedAt) / 1000
    if (age <= options.freshTtl) return entry.data

    // Stale — revalidate in background, return old data now
    revalidateInBackground(key, fetcher, options.staleTtl)
    return entry.data
  }

  const data = await fetcher()
  await redis.setex(key, options.staleTtl, JSON.stringify({ data, cachedAt: Date.now() }))
  return data
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Session Storage

// lib/sessions.ts
export async function createSession(data: Omit<Session, 'createdAt'>): Promise<string> {
  const sessionId = randomBytes(32).toString('hex')
  const session: Session = { ...data, createdAt: Date.now() }
  await redis.setex(`session:${sessionId}`, SESSION_TTL, JSON.stringify(session))
  return sessionId
}

export async function getSession(sessionId: string): Promise<Session | null> {
  return redis.get<Session>(`session:${sessionId}`)
}
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Rate Limiting

import { Ratelimit } from '@upstash/ratelimit'

export const apiRateLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
})

// In a Route Handler:
const { success, limit, remaining } = await apiRateLimit.limit(ip)
if (!success) {
  return Response.json({ error: 'Too many requests' }, { status: 429 })
}
Enter fullscreen mode Exit fullscreen mode

Pattern 6: Distributed Locks

export async function acquireLock(resource: string, ttl: number): Promise<string | null> {
  const lockId = crypto.randomUUID()
  const result = await redis.set(`lock:${resource}`, lockId, { nx: true, ex: ttl })
  return result === 'OK' ? lockId : null
}
Enter fullscreen mode Exit fullscreen mode

What to Cache vs What Not to Cache

Cache: Database query results, external API responses, computed/aggregated data, feature flag states

Don't cache: Auth checks, financial transactions, real-time inventory, user-specific private data without scoping to user ID

Upstash Pricing

Tier Price Commands/day
Free $0 10,000
Pay-as-you-go $0.20/100K Unlimited
Pro $10/month Unlimited

For the complete guide with all patterns, code examples, and Middleware-level caching: stacknotice.com/blog/upstash-redis-nextjs-complete-guide-2026

Top comments (0)