Upstash Redis + Next.js: The Complete Guide (2026)
Source: Dev.to
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(
key: string,
fetcher: () => Promise,
options: { ttl?: number; prefix?: string } = {}
): Promise {
const { ttl = 300, prefix = 'cache' } = options
const cacheKey = `${prefix}:${key}`
const cached = await redis.get(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
}
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(
key: string,
fetcher: () => Promise,
options: { freshTtl: number; staleTtl: number }
): Promise {
const entry = await redis.get(key)
if (entry !== null) {
const age = (Date.now() - entry.cachedAt) / 1000
if (age ): Promise {
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 {
return redis.get(`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 {
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
