javascript
45 lines · 8 steps
Sliding-window rate limiting in a Next.js route
A Next.js API route caps requests per IP using a Redis sorted set that tracks each hit's timestamp.
Explained by
highlit
1import { NextResponse } from 'next/server';
2import { Redis } from '@upstash/redis';
3
4const redis = Redis.fromEnv();
5
6const WINDOW_MS = 60_000;
7const MAX_REQUESTS = 30;
8
9async function checkRateLimit(ip) {
10 const now = Date.now();
11 const windowStart = now - WINDOW_MS;
12 const key = `ratelimit:${ip}`;
13
14 const pipeline = redis.multi();
15 pipeline.zremrangebyscore(key, 0, windowStart);
16 pipeline.zadd(key, { score: now, member: `${now}-${Math.random()}` });
17 pipeline.zcard(key);
18 pipeline.pexpire(key, WINDOW_MS);
19
20 const [, , count] = await pipeline.exec();
21
22 return {
23 allowed: count <= MAX_REQUESTS,
24 remaining: Math.max(0, MAX_REQUESTS - count),
25 };
26}
27
28export async function GET(request) {
29 const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '127.0.0.1';
30 const { allowed, remaining } = await checkRateLimit(ip);
31
32 const headers = {
33 'X-RateLimit-Limit': String(MAX_REQUESTS),
34 'X-RateLimit-Remaining': String(remaining),
35 };
36
37 if (!allowed) {
38 return NextResponse.json(
39 { error: 'Too many requests' },
40 { status: 429, headers: { ...headers, 'Retry-After': String(WINDOW_MS / 1000) } },
41 );
42 }
43
44 return NextResponse.json({ data: 'ok' }, { headers });
45}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A sorted set scored by timestamp turns rate limiting into a running count of hits inside a moving time window.
- 2Batching Redis commands in a pipeline makes the read-modify-count sequence a single atomic round trip.
- 3Returning limit and remaining headers lets clients self-throttle before they ever hit a 429.
Related explainers
javascript
const MAX_FILE_SIZE = 5 * 1024 * 1024; const ALLOWED_TYPES = { 'image/jpeg': ['jpg', 'jpeg'],
Validating file uploads by content, not just claims
input-validation
security
magic-bytes
Intermediate
7 steps
javascript
function zip(keys, values) { if (keys.length !== values.length) { throw new RangeError('zip expects arrays of equal length'); }
Three ways to zip arrays in JavaScript
arrays
higher-order-functions
pairing
Intermediate
6 steps
javascript
import { useCallback, useEffect, useState } from 'react'; export function useLocalStorage(key, initialValue) { const readValue = useCallback(() => {
How a useLocalStorage hook syncs state in React
custom hooks
localstorage
state persistence
Intermediate
8 steps
go
func UserDashboardCache(rdb *redis.Client, ttl time.Duration) gin.HandlerFunc { return func(c *gin.Context) { claims, ok := c.Get("claims") if !ok {
Per-user response caching in Gin with Redis
middleware
caching
redis
Advanced
9 steps
javascript
import { useState } from 'react'; export function ReorderableList({ initialItems }) { const [items, setItems] = useState(initialItems);
Drag-to-reorder lists in React
drag-and-drop
state-management
immutable-updates
Intermediate
8 steps
javascript
import { unstable_cache, revalidateTag } from 'next/cache' import { db } from '@/lib/db' export const getDashboardStats = unstable_cache(
Caching dashboard stats in Next.js
caching
cache-invalidation
tag-based-revalidation
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/sliding-window-rate-limiting-in-a-next-js-route-explained-javascript-7cdd/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.