Most "Upstash quickstart" guides show you a single GET and SET. The pattern that earns Upstash its place in a serverless stack is the one below: a sliding-window rate limiter and an idempotency-token check, both running inside a Server Action, both backed by the same Redis instance, with the Stripe webhook pattern from earlier in the stack borrowing the same idempotency primitive.
1. The rate limiter
@upstash/ratelimit does the algorithmic correctness inside Redis with MULTI/EXEC. The application code is one line at the call site.
// src/lib/upstash/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
export const ipLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '10 s'),
prefix: 'rl:ip',
analytics: true,
})
export const userLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '60 s'),
prefix: 'rl:user',
analytics: true,
})
export const tenantLimiter = new Ratelimit({
redis,
limiter: Ratelimit.tokenBucket(1000, '60 s', 1000),
prefix: 'rl:tenant',
analytics: true,
})
export async function checkRate(opts: {
ip: string
userId: string
tenantId: string
}): Promise<
| { ok: true }
| { ok: false; scope: 'ip' | 'user' | 'tenant'; reset: number }
> {
const ipResult = await ipLimiter.limit(opts.ip)
if (!ipResult.success) return { ok: false, scope: 'ip', reset: ipResult.reset }
const userResult = await userLimiter.limit(opts.userId)
if (!userResult.success) return { ok: false, scope: 'user', reset: userResult.reset }
const tenantResult = await tenantLimiter.limit(opts.tenantId)
if (!tenantResult.success) return { ok: false, scope: 'tenant', reset: tenantResult.reset }
return { ok: true }
}
2. The idempotency helper
A unique key encodes the request shape; the response is cached against it. A retry with the same key returns the original response without re-running the work.
// src/lib/upstash/idempotency.ts
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
const TTL_SECONDS = 24 * 60 * 60
interface RecordedResult<T> {
status: 'success' | 'error'
response: T
}
export async function withIdempotency<T>(
key: string,
run: () => Promise<T>,
): Promise<T> {
const fullKey = `idem:${key}`
const existing = await redis.get<RecordedResult<T>>(fullKey)
if (existing) {
if (existing.status === 'error') {
throw new Error('previous attempt failed; retry with a different key')
}
return existing.response
}
try {
const result = await run()
await redis.set(
fullKey,
{ status: 'success', response: result } satisfies RecordedResult<T>,
{ ex: TTL_SECONDS },
)
return result
} catch (err) {
await redis.set(
fullKey,
{ status: 'error', response: null } satisfies RecordedResult<unknown>,
{ ex: 60 },
)
throw err
}
}
3. The Server Action that uses both
The Server Action takes an Idempotency-Key header, runs the rate limiter, then runs the business logic inside the idempotency helper. A retried POST with the same key returns the original response; a duplicate write never happens.
// app/[lang]/(app)/invoices/actions.ts
'use server'
import { headers } from 'next/headers'
import { z } from 'zod'
import { checkRate } from '@/lib/upstash/ratelimit'
import { withIdempotency } from '@/lib/upstash/idempotency'
import { getServerSession } from '@/lib/auth/server'
import { stripe } from '@/lib/stripe/server'
const Input = z.object({
customerId: z.string(),
amountCents: z.number().int().positive(),
description: z.string().min(1).max(200),
})
export async function createInvoice(
raw: unknown,
): Promise<
| { ok: true; invoiceId: string }
| { ok: false; error: string; reset?: number }
> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const hdrs = await headers()
const ip = hdrs.get('x-forwarded-for')?.split(',')[0] ?? '0.0.0.0'
const idempotencyKey = hdrs.get('idempotency-key')
if (!idempotencyKey) return { ok: false, error: 'missing idempotency key' }
const rate = await checkRate({
ip,
userId: session.userId,
tenantId: session.tenantId,
})
if (!rate.ok) {
return { ok: false, error: `rate limited (${rate.scope})`, reset: rate.reset }
}
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
return withIdempotency(`invoice:${session.tenantId}:${idempotencyKey}`, async () => {
const invoice = await stripe.invoices.create({
customer: parsed.data.customerId,
collection_method: 'send_invoice',
days_until_due: 30,
description: parsed.data.description,
metadata: { tenant_id: session.tenantId },
})
await stripe.invoiceItems.create({
customer: parsed.data.customerId,
amount: parsed.data.amountCents,
currency: 'eur',
invoice: invoice.id,
})
return { ok: true as const, invoiceId: invoice.id }
})
}
4. What this buys you
The rate limiter runs in a couple of milliseconds at the edge. The idempotency helper turns a retried POST into a cache lookup, so the Stripe API never sees a duplicate. The whole flow lives in three files; the Redis layer never appears in the application logic except as two helpers that read like normal functions.
This is the Upstash that earns its place: not a "we replaced Redis with HTTP" curiosity, but the operational layer that makes serverless deploys actually safe under retries and high traffic.