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
// 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!,
})
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
}
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} />
}
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))
}
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
}
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}`)
}
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 })
}
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
}
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)