EmpireUI
Get Pro
← Blog9 min read#upstash#redis#rate limiting

Upstash Redis Guide: Rate Limiting, Caching and Pub/Sub in Next.js

Learn to wire Upstash Redis into a Next.js app for rate limiting, edge caching, and pub/sub — with real code and zero server management.

abstract glowing data streams representing Redis cache operations

Why Upstash Beats Running Redis Yourself

If you've ever spun up a Redis container in production, you already know the pain — connection pooling headaches, out-of-memory kills at 3am, and that one devops ticket that sits in the backlog for six months. Upstash removes all of that. It's serverless Redis with a per-request pricing model, an HTTP-based driver, and a free tier that's actually usable.

The HTTP transport is the real unlock here. Regular Redis clients hold a TCP connection open, which falls apart fast on serverless functions and Vercel Edge Middleware. Upstash's @upstash/redis package uses fetch under the hood, so it works in any V8 environment — Edge Runtime, Cloudflare Workers, Deno, you name it. Worth noting: the package has been stable since 2022 and the API is nearly identical to ioredis, so switching existing code over takes maybe 20 minutes.

Honestly, the free tier alone covers most hobby projects and small SaaS apps. You get 10,000 daily commands and 256 MB storage. Once you outgrow it, the pay-per-request pricing is predictable in a way that managed Redis clusters never are. No minimum monthly bill sitting there even when you're not using it.

One more thing — Upstash integrates directly with the Vercel marketplace, so you can provision a database from the Vercel dashboard in about 30 seconds. The environment variables land in your project automatically. That's a genuinely nice quality-of-life thing when you're already deep in a Next.js deploy.

Installation and Initial Setup

First, create a database at console.upstash.com. Pick the region closest to your users — latency at the edge matters more than you'd think, especially for rate limiting where you're hitting Redis on every single request. Once it's created, copy the UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN values into your .env.local.

npm install @upstash/redis

Then initialize the client. You'll typically want a singleton so you're not creating a new client on every import — especially relevant in Next.js where module scope persists across requests in a single worker.

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

That's literally it. You can now call redis.set, redis.get, redis.incr — the full command surface — from any Next.js Route Handler, Server Action, or Middleware file. Quick aside: if you're using the Vercel integration, the environment variable names match exactly, so no renaming needed.

Rate Limiting API Routes with @upstash/ratelimit

The @upstash/ratelimit package is where Upstash really earns its keep. It implements sliding window, fixed window, and token bucket algorithms on top of Redis, all in about 10 lines of code on your end. Rate limiting an API route that previously had zero protection takes under five minutes.

npm install @upstash/ratelimit

Here's a sliding window limiter applied to a Next.js Route Handler. The sliding window algorithm is almost always what you want — it doesn't have the burst-at-boundary problem that fixed windows do.

// app/api/submit/route.ts
import { Ratelimit } from '@upstash/ratelimit'
import { redis } from '@/lib/redis'
import { NextRequest, NextResponse } from 'next/server'

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
  analytics: true,
})

export async function POST(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1'
  const { success, limit, reset, remaining } = await ratelimit.limit(ip)

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      {
        status: 429,
        headers: {
           'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      }
    )
  }

  // your actual handler logic here
  return NextResponse.json({ ok: true })
}

In practice, the analytics: true flag is worth enabling even in development. It writes extra data to Redis that the Upstash console can visualize — you get a per-identifier breakdown of request rates without needing to set up any external observability tooling. For Middleware-level rate limiting (protecting whole route segments), the pattern is identical, you just move the ratelimit.limit() call into middleware.ts and return a 429 response early.

Caching API Responses and Server Component Data

Caching with Upstash is just set with an expiry and get on the way in. The real design question is where in your Next.js data layer you put it. Server Components and Route Handlers both work fine. The pattern below is a stale-while-revalidate style cache: you always serve the cached value if it exists, and let the next caller (or a background job) refresh it.

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

export async function cachedFetch<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttlSeconds = 60
): Promise<T> {
  const cached = await redis.get<T>(key)
  if (cached !== null) return cached

  const fresh = await fetcher()
  await redis.set(key, JSON.stringify(fresh), { ex: ttlSeconds })
  return fresh
}

Call it from a Server Component or Route Handler like this:

// app/products/page.tsx (Server Component)
import { cachedFetch } from '@/lib/cache'

export default async function ProductsPage() {
  const products = await cachedFetch(
    'products:all',
    () => fetch('https://api.example.com/products').then(r => r.json()),
    300 // 5 minute TTL
  )

  return <ProductList products={products} />
}

Worth noting: Next.js 15's fetch caching and Upstash caching serve different purposes. Next.js caches deduplicate within a single render tree per request. Upstash caches persist between requests and across all instances of your app. You want both in many cases — Next.js deduplication handles the fan-out within one SSR pass, while Upstash prevents the database hit on the next page load entirely.

For cache invalidation, the cleanest pattern is key-based namespacing. Prefix all your keys — products:all, user:42:profile — so you can invalidate by pattern using SCAN + DEL when data changes. Don't use KEYS * in production; it blocks the Redis event loop and your Upstash account manager will not be happy.

Pub/Sub for Real-Time Features

Upstash Redis supports pub/sub via the standard PUBLISH and SUBSCRIBE commands. That said, serverless environments and long-lived subscriptions are a mismatch by design — a Lambda or Edge Function gets killed after the request completes, so you can't hold a SUBSCRIBE connection open. The pattern that actually works is using Upstash pub/sub for fan-out between persistent workers (like a Node.js process or a Cloudflare Durable Object), not between serverless functions.

For most Next.js apps, the practical use case is broadcasting cache invalidation signals or triggering background jobs. Publish an event when a user updates their data; a separate worker subscribes and refreshes the cache. It's a clean separation of concerns.

// Publishing from a Route Handler (fire-and-forget)
import { redis } from '@/lib/redis'

export async function POST(req: Request) {
  const body = await req.json()
  await redis.publish('cache:invalidate', JSON.stringify({ key: `user:${body.userId}:profile` }))
  return Response.json({ ok: true })
}
// Worker process (long-running Node.js, not serverless)
import { Redis } from '@upstash/redis'

// Use the Node.js ioredis client here for real SUBSCRIBE support
import Redis as IORedis from 'ioredis'

const sub = new IORedis(process.env.UPSTASH_REDIS_URL!)
sub.subscribe('cache:invalidate')
sub.on('message', async (channel, message) => {
  const { key } = JSON.parse(message)
  await sub.del(key)
  console.log(`Invalidated cache key: ${key}`)
})

Look, the honest answer is: if you need real-time bidirectional communication in your Next.js app, Upstash pub/sub is a piece of the puzzle, not the whole solution. You'll typically pair it with Server-Sent Events or WebSockets for the browser layer. Upstash handles the server-to-server signaling reliably; don't try to turn it into a full WebSocket replacement.

Middleware Rate Limiting at the Edge

One of the most powerful Upstash patterns is running rate limiting in Next.js Middleware — before your Route Handlers even execute. This means a DDoS or a scraper hits a 429 at the CDN edge, never touching your database or application server. The latency overhead is typically 2-8ms depending on region co-location.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
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!,
})

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 req/min globally
})

export const config = {
  matcher: ['/api/:path*'],
}

export async function middleware(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for') ?? req.ip ?? '127.0.0.1'
  const { success } = await ratelimit.limit(ip)

  if (!success) {
    return new NextResponse('Too Many Requests', { status: 429 })
  }

  return NextResponse.next()
}

The matcher config is important — you don't want to rate limit static assets or _next/static paths, only your API routes (and maybe auth endpoints). Matching /api/:path* covers the common case.

In practice, you might want different limits for different route segments — stricter on /api/auth/*, more lenient on /api/public/*. You can do this by checking req.nextUrl.pathname inside the middleware and instantiating different Ratelimit instances. They each use a different Redis key prefix by default, so there's no interference between them.

If you're building a UI-heavy project alongside this backend work, it's worth checking out Empire UI for pre-built React components — things like toast notifications for showing rate limit errors to users, or loading states while waiting on cached data to refresh.

Patterns Worth Knowing Before You Ship

A few things that bite people once they're past the tutorial phase. First: Upstash has a 1 MB max payload size per command. If you're trying to cache a massive JSON response, you'll hit this silently — the set will fail and your get will return null, making it look like a cache miss every time. Either compress your payloads with pako or split them across multiple keys.

Second, key TTLs don't reset on read by default. If you want a 'last-accessed' style expiry that refreshes whenever the key is read, you need to explicitly call redis.expire(key, ttlSeconds) after your get. Forgetting this leads to hot keys expiring under load, which is a fun production incident to debug at 11pm.

Third, Upstash's free tier limits to 100 concurrent connections. That's not a problem for typical Next.js apps where connections are short-lived HTTP requests, but if you're running a dev environment with hot reload hammering Redis on every save, you might occasionally see connection errors. Just add a small retry with exponential backoff around your Redis calls.

// lib/redis-with-retry.ts
import { redis } from './redis'

export async function redisWithRetry<T>(
  operation: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await operation()
    } catch (err) {
      if (attempt === maxRetries - 1) throw err
      await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 100))
    }
  }
  throw new Error('Max retries exceeded')
}

For the frontend side, if you're building dashboards or admin panels that surface rate limit stats or cache hit rates, you'd want something polished. The box shadow generator and gradient generator over at Empire UI are solid for knocking out CSS details fast while you focus on the backend logic. One more thing — run DBSIZE on your Upstash console periodically. Accumulated rate limit keys from old IPs add up faster than you'd expect, and a cleanup script that DELs expired prefixes monthly is worth having.

FAQ

Can I use Upstash Redis in Next.js Edge Middleware?

Yes, and it's one of the best use cases. The @upstash/redis package uses fetch instead of a TCP socket, so it works in the Edge Runtime with zero configuration changes. Just import it in middleware.ts and use it normally.

What's the difference between Upstash's fixed window and sliding window rate limiting?

Fixed window resets the counter at hard time boundaries — so a user can burst 10 requests at 00:59 and another 10 at 01:00. Sliding window tracks a rolling time frame, which prevents that boundary burst. Use sliding window unless you have a specific reason not to.

Is Upstash Redis suitable for session storage in Next.js?

It works, but you'd typically use it with a session library like iron-session or next-auth that handles the cookie/token layer. Upstash becomes the backing store where session data actually lives, with a TTL matching your session timeout.

How do I handle Upstash rate limiting for authenticated users vs anonymous IPs?

Pass a different identifier string to ratelimit.limit() — use the user's ID for authenticated requests and the IP for anonymous ones. You can also instantiate two separate Ratelimit objects with different limits and call the appropriate one based on whether the user is logged in.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Better Auth Guide 2026: Sessions, Social, 2FA in Next.jsUploadThing Guide: File Uploads in Next.js Without the S3 Config PainNext.js Caching in 2026: fetch, ISR, Dynamic and No-Store ExplainedNext.js Caching Deep Dive: Request, Data, Full Route and Client Cache