Rate Limiting a Next.js API with Redis

Add distributed rate limiting to a Next.js API using Upstash Redis.

Introduction

30 Second Summary

Every time you've been locked out of an account after too many failed password attempts, you've experienced rate limiting in action. It's the invisible bouncer that keeps APIs from being overwhelmed by bots, brute-force attacks, or a single user hogging all the resources.

In this project, you will add production-ready rate limiting to a Next.js API using Upstash Redis. You'll implement multiple rate limiting algorithms, protect different endpoints with tailored limits, and verify everything works with an automated test script.

What You'll Build

A two-tier rate limiting system for a running apparel recommendation API, backed by distributed Redis and implementing sliding window and fixed window algorithms.

By the end of this project, you'll have:

  • A reusable rate limiter module with multiple algorithms (sliding window, fixed window) powered by Upstash Redis.
  • Global IP-based rate limiting via Next.js middleware that catches abuse before it reaches your route handlers.
  • Per-endpoint protection with strict brute-force limits on auth and user-based limits on recommendations, verified by an automated test script that confirms HTTP 429 responses and standard rate limit headers.
  • Secret Mission: Switch to a token bucket algorithm for burst-tolerant recommendations and build an admin analytics endpoint.

Are there any prerequisites?

You should be familiar with Next.js API routes and TypeScript. You'll also need an existing Next.js project (or be willing to create a new one for practice).

Build the Rate Limiter Module

Your Next.js app can talk to Upstash Redis. Now you need a centralized module that defines your rate limiting policies.

Instead of scattering rate limiting logic across every route handler, you will create a single lib/rate-limit.ts file that exports pre-configured limiters and helpers. Each limiter uses a different algorithm matched to its security goal.

In this step, get ready to:
  • Create three rate limiter instances with different algorithms and thresholds.
  • Build helper functions for IP detection and HTTP header generation.
  • Verify the module compiles with TypeScript.
Create the rate limiter instances

Each API route in your app faces a different kind of abuse. Login endpoints need strict brute-force protection. Recommendation endpoints need per-user fairness. A global layer catches broad flooding before requests even reach a route handler.

You will configure three separate limiters, each with its own algorithm, threshold, and Redis key prefix.

  • Create a lib folder inside your project root (next to your app/ directory), then create a file called rate-limit.ts inside it by running these commands:
mkdir -p lib
touch lib/rate-limit.ts
  • Open lib/rate-limit.ts in your editor and paste the following code:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

// Global: 20 requests per 10 seconds per IP (sliding window)
export const globalLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, "10 s"),
  analytics: true,
  prefix: "runlayer:global",
});

// Auth: 5 attempts per 60 seconds per IP (fixed window)
export const authLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.fixedWindow(5, "60 s"),
  analytics: true,
  prefix: "runlayer:auth",
});

// Recommendations: 10 requests per 60 seconds per user (sliding window)
export const recommendationsLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, "60 s"),
  analytics: true,
  prefix: "runlayer:recommendations",
});

What does this code do?

Redis.fromEnv() reads your UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN environment variables to connect to your Upstash database over HTTP.

  • globalLimiter uses a sliding window algorithm. It allows 20 requests per 10 seconds per IP. Sliding windows spread the limit evenly over time, preventing bursts right at window boundaries.
  • authLimiter uses a fixed window algorithm. It allows 5 attempts per 60 seconds per IP. Fixed windows are cheaper in Redis commands and sufficient for login endpoints where attempts are already low-frequency.
  • recommendationsLimiter uses a sliding window at 10 requests per 60 seconds per user. This ensures fair access so one heavy user cannot exhaust the recommendation service.
  • Each prefix gives the limiter a unique namespace in Redis. This keeps rate limit counters separate so they never collide.

Why sliding window for global but fixed window for auth?

A sliding window costs about 4 Redis commands per request. A fixed window costs about 2. Since auth endpoints are already low-frequency and the main goal is a hard cap on attempts, the simpler fixed window is a better fit.

The global limiter and recommendations limiter use sliding window because they handle higher traffic and need to prevent boundary bursts where a user could fire 20 requests at the end of one window and 20 more at the start of the next.

  • Save lib/rate-limit.ts. You will not see output yet. The next substep adds helper functions, then you will verify the full module compiles.

Seeing red squiggles on the imports?

If your editor shows errors on the import lines, confirm that @upstash/ratelimit and @upstash/redis appear in your package.json under dependencies. If they are missing, re-run the install command from the previous step.

help me fix import errors in my rate-limit module

Add helper functions for IP detection and headers

Rate limiters need to know WHO is making the request. In serverless environments, the client IP comes from proxy headers rather than the direct connection. You also need a consistent way to attach rate limit metadata to every response.

  • In lib/rate-limit.ts, add the following two functions below your recommendationsLimiter definition:
// Helper: Extract client IP from request headers
export function getClientIp(request: Request): string {
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) {
    return forwarded.split(",")[0].trim();
  }
  const realIp = request.headers.get("x-real-ip");
  if (realIp) {
    return realIp;
  }
  return "anonymous";
}

// Helper: Build rate limit headers object
export function rateLimitHeaders(
  limit: number,
  remaining: number,
  reset: number
): Record<string, string> {
  return {
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": remaining.toString(),
    "X-RateLimit-Reset": reset.toString(),
  };
}

What do these helpers do?

getClientIp checks the x-forwarded-for header first (set by proxies like Vercel or AWS load balancers), then falls back to x-real-ip. If neither header exists, it returns "anonymous" so the limiter still functions.

rateLimitHeaders builds an object with the three standard rate limit headers: X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Attaching these to every response lets clients know their current quota without guessing.

  • Save lib/rate-limit.ts.

Type error on Request?

If TypeScript cannot find the Request type, check that your tsconfig.json includes "DOM" in the lib array. Next.js projects include this by default, so if you see this error, your tsconfig may have been manually edited.

help me fix the Request type error in my rate-limit module

Verify the module compiles

Before using this module in your routes, confirm that TypeScript is happy with every type and import.

  • Run the TypeScript compiler in check-only mode by running this command:
npx tsc --noEmit
  • If the command finishes with no output, your module is valid. No output means no errors.

Use your editor instead

If you have TypeScript checking enabled in VS Code or Cursor, you can also verify by opening lib/rate-limit.ts and confirming there are no red or yellow underlines. Both approaches confirm the same thing.

Seeing compilation errors?

If you see errors about missing modules, confirm @upstash/ratelimit and @upstash/redis are installed by checking your node_modules folder. If you see type errors about Request or Record, ensure your tsconfig.json has not been stripped of default Next.js settings.

help me debug TypeScript compilation errors in my rate-limit module

✔️ Awesome, I've got everything!

Great. Make sure you have saved lib/rate-limit.ts and that TypeScript reports no errors.

ⓧ I'd like to double check the full code

Here is the complete lib/rate-limit.ts file. Compare it against yours line by line:

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

// Global: 20 requests per 10 seconds per IP (sliding window)
export const globalLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, "10 s"),
  analytics: true,
  prefix: "runlayer:global",
});

// Auth: 5 attempts per 60 seconds per IP (fixed window)
export const authLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.fixedWindow(5, "60 s"),
  analytics: true,
  prefix: "runlayer:auth",
});

// Recommendations: 10 requests per 60 seconds per user (sliding window)
export const recommendationsLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, "60 s"),
  analytics: true,
  prefix: "runlayer:recommendations",
});

// Helper: Extract client IP from request headers
export function getClientIp(request: Request): string {
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) {
    return forwarded.split(",")[0].trim();
  }
  const realIp = request.headers.get("x-real-ip");
  if (realIp) {
    return realIp;
  }
  return "anonymous";
}

// Helper: Build rate limit headers object
export function rateLimitHeaders(
  limit: number,
  remaining: number,
  reset: number
): Record<string, string> {
  return {
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": remaining.toString(),
    "X-RateLimit-Reset": reset.toString(),
  };
}

Your rate limiter module is ready. Next up, you will wire the global limiter into Next.js middleware so every API request gets checked before it reaches a route handler.

Implement Global Middleware Rate Limiting

Your rate limiter module defines the rules, but it's not protecting anything yet. To activate it, you need to place it in the request pipeline so every incoming API call passes through it before reaching your route handlers.

Next.js provides a middleware system that intercepts requests before they reach your route handlers. In this step, you'll wire up the global rate limiter as middleware, returning proper HTTP 429 responses when clients exceed the limit.

In this step, get ready to:
  • Create middleware.ts at the project root with global rate limiting logic.
  • Start the dev server and verify rate limit headers appear on API responses.
  • Confirm that exceeding 20 requests in 10 seconds triggers a 429 block.
Create the global rate limiting middleware

Next.js middleware lives in a file called middleware.ts at the project root (the same level as your app/ directory). It runs on the server before a request reaches your route handlers, making it the perfect place for a global rate limit check.

  • Create a new file called middleware.ts in the root of your project (next to app/ and lib/).
  • Paste the following code into middleware.ts:
import { NextRequest, NextResponse } from "next/server";
import { globalLimiter, getClientIp } from "@/lib/rate-limit";

export async function middleware(request: NextRequest) {
  if (!request.nextUrl.pathname.startsWith("/api/")) {
    return NextResponse.next();
  }

  const ip = getClientIp(request);

  const { success, limit, remaining, reset } = await globalLimiter.limit(ip);

  if (!success) {
    return new NextResponse(
      JSON.stringify({
        error: "Too many requests",
        message: "You have exceeded the rate limit. Please try again later.",
        retryAfter: Math.ceil((reset - Date.now()) / 1000),
      }),
      {
        status: 429,
        headers: {
          "Content-Type": "application/json",
          "X-RateLimit-Limit": limit.toString(),
          "X-RateLimit-Remaining": "0",
          "X-RateLimit-Reset": reset.toString(),
          "Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      }
    );
  }

  const response = NextResponse.next();
  response.headers.set("X-RateLimit-Limit", limit.toString());
  response.headers.set("X-RateLimit-Remaining", remaining.toString());
  response.headers.set("X-RateLimit-Reset", reset.toString());
  return response;
}

export const config = {
  matcher: "/api/:path*",
};

What does this code do?

  • The config.matcher at the bottom tells Next.js to only run this middleware on paths matching /api/:path*. The startsWith("/api/") check inside the function is a safety guard in case the matcher is ever removed.
  • The getClientIp(request) helper extracts the real client IP from proxy headers, which is the identifier used for rate limiting.
  • When success is false, the user exceeded 20 requests in 10 seconds. The middleware short-circuits with a 429 response, a JSON error body, and standard rate limit headers including Retry-After (in seconds).
  • When success is true, the request passes through to the route handler via NextResponse.next(), but the response still carries rate limit headers so clients always know their remaining quota.

Why use middleware instead of route handlers?

Middleware runs before your route handlers even load. This means abusive traffic gets blocked at the edge of your application, saving compute for legitimate users.

Per-endpoint limits (which you'll add in the next step) go inside route handlers because they need endpoint-specific context like user identity. The global limit here is a broad safety net that catches abuse early.

  • Save middleware.ts.

TypeScript showing import errors?

  • Make sure middleware.ts is at the project root, not inside app/ or lib/.
  • Check that your tsconfig.json has the @/ path alias configured (Next.js sets this up by default).
  • Verify lib/rate-limit.ts exports both globalLimiter and getClientIp as named exports.

Help me fix TypeScript import errors in my Next.js middleware file

Test the middleware locally

With the middleware in place, every request to any /api/ path now passes through the global rate limiter. Time to confirm it works.

  • Start your dev server by running:
npm run dev
  • Open a new terminal tab and send a single request to any /api/ path by running:
curl -i http://localhost:3000/api/test
  • Look at the response headers in the terminal output. You should see X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset in the response.

Why does this work even without an API route?

Next.js middleware runs before route resolution. Even if /api/test doesn't exist as a route handler, the middleware still intercepts the request and attaches rate limit headers. The final response might be a 404, but the headers confirm your middleware is active.

Now confirm that exceeding the limit triggers a 429 block.

  • Send 25 rapid requests by running this loop:
for i in $(seq 1 25); do
  response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/test)
  echo "Request $i: $response"
done
  • Watch the output. The first 20 requests should return 200 (or 404). Starting at request 21, you should see 429 responses.
  • To see the full 429 response body with headers, send one more request by running:
curl -i http://localhost:3000/api/test
  • You should see a JSON body with "error": "Too many requests" and headers including Retry-After.

Rate limit window resets quickly

The global limiter uses a 10-second sliding window. If you wait 10 seconds, you can run the test loop again and see it pass from the beginning.

Not seeing rate limit headers?

  • Make sure you restarted the dev server after creating middleware.ts. Next.js needs a restart to detect new middleware files.
  • Check that .env.local has both UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN set correctly. The dev server must be restarted after changing environment variables.
  • If requests return 500, check your terminal running npm run dev for error messages. A common cause is incorrect Upstash credentials.

Help me debug my middleware rate limiting setup

✔️ Awesome, I've got everything!

Great. Make sure you've saved middleware.ts and your dev server is still running for the next step.

ⓧ I'd like to double check the full code

Here is the complete middleware.ts file:

import { NextRequest, NextResponse } from "next/server";
import { globalLimiter, getClientIp } from "@/lib/rate-limit";

export async function middleware(request: NextRequest) {
  if (!request.nextUrl.pathname.startsWith("/api/")) {
    return NextResponse.next();
  }

  const ip = getClientIp(request);

  const { success, limit, remaining, reset } = await globalLimiter.limit(ip);

  if (!success) {
    return new NextResponse(
      JSON.stringify({
        error: "Too many requests",
        message: "You have exceeded the rate limit. Please try again later.",
        retryAfter: Math.ceil((reset - Date.now()) / 1000),
      }),
      {
        status: 429,
        headers: {
          "Content-Type": "application/json",
          "X-RateLimit-Limit": limit.toString(),
          "X-RateLimit-Remaining": "0",
          "X-RateLimit-Reset": reset.toString(),
          "Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      }
    );
  }

  const response = NextResponse.next();
  response.headers.set("X-RateLimit-Limit", limit.toString());
  response.headers.set("X-RateLimit-Remaining", remaining.toString());
  response.headers.set("X-RateLimit-Reset", reset.toString());
  return response;
}

export const config = {
  matcher: "/api/:path*",
};

And lib/rate-limit.ts remains unchanged from the previous step:

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

// Global: 20 requests per 10 seconds per IP (sliding window)
export const globalLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, "10 s"),
  analytics: true,
  prefix: "runlayer:global",
});

// Auth: 5 attempts per 60 seconds per IP (fixed window)
export const authLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.fixedWindow(5, "60 s"),
  analytics: true,
  prefix: "runlayer:auth",
});

// Recommendations: 10 requests per 60 seconds per user (sliding window)
export const recommendationsLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, "60 s"),
  analytics: true,
  prefix: "runlayer:recommendations",
});

// Helper: Extract client IP from request headers
export function getClientIp(request: Request): string {
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) {
    return forwarded.split(",")[0].trim();
  }
  const realIp = request.headers.get("x-real-ip");
  if (realIp) {
    return realIp;
  }
  return "anonymous";
}

// Helper: Build rate limit headers object
export function rateLimitHeaders(
  limit: number,
  remaining: number,
  reset: number
): Record<string, string> {
  return {
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": remaining.toString(),
    "X-RateLimit-Reset": reset.toString(),
  };
}

Your global rate limiter is live. Every API request now passes through the middleware and gets tracked against a 20-request-per-10-second window. Next up, you'll add per-endpoint limits with different thresholds for auth and recommendations.

Add Per-Endpoint Auth and User-Based Limits

Your middleware now catches broad abuse at the global level. Every API request passes through a single IP-based rate limit before it even reaches your route handlers.

Global protection is just the first layer. A login route needs strict limits to block brute-force attacks, while a recommendations endpoint should throttle per user so one person's activity doesn't affect everyone else. In this step, you'll wire up both patterns using the limiters you already defined in lib/rate-limit.ts.

In this step, get ready to:
  • Create a login endpoint with strict IP-based rate limiting.
  • Create a recommendations endpoint with user-based rate limiting.
  • Test per-user isolation to confirm limits track independently.
Create the auth login route

Login endpoints are prime targets for brute-force attacks. An attacker can try thousands of password combinations in seconds. The authLimiter you configured earlier uses a fixed window algorithm to cap login attempts at 5 per 60 seconds per IP.

  • Create the directory structure for the auth route by running this command:
mkdir -p app/api/auth/login
  • Confirm the directory exists by running:
ls app/api/auth/login

You should see an empty directory listed with no errors.

  • Create a new file called route.ts inside app/api/auth/login/ and add the following code:
import { NextRequest } from "next/server";
import { authLimiter, getClientIp, rateLimitHeaders } from "@/lib/rate-limit";

export async function POST(request: NextRequest) {
  const ip = getClientIp(request);

  const { success, limit, remaining, reset } = await authLimiter.limit(ip);

  if (!success) {
    return Response.json(
      {
        error: "Too many login attempts",
        message: "Please wait before trying again.",
        retryAfter: Math.ceil((reset - Date.now()) / 1000),
      },
      {
        status: 429,
        headers: {
          ...rateLimitHeaders(limit, 0, reset),
          "Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      }
    );
  }

  const body = await request.json();
  const { email, password } = body;

  // Simulated auth logic (replace with real Prisma/JWT logic in RunLayer)
  if (email === "runner@runlayer.com" && password === "trailblazer") {
    return Response.json(
      {
        user: { id: "user_123", email, name: "Trail Runner" },
        token: "mock-jwt-token",
      },
      {
        headers: rateLimitHeaders(limit, remaining, reset),
      }
    );
  }

  return Response.json(
    { error: "Invalid credentials" },
    {
      status: 401,
      headers: rateLimitHeaders(limit, remaining, reset),
    }
  );
}

What does this code do?

This route handler applies per-endpoint rate limiting on top of the global middleware limit.

  • authLimiter.limit(ip) checks the IP against the fixed window of 5 attempts per 60 seconds. If the IP already used all 5 attempts, the request is blocked immediately with a 429.
  • rateLimitHeaders(limit, 0, reset) spreads the standard rate limit headers into the 429 response. The 0 means no remaining attempts.
  • The credential check is hardcoded for testing. In a real RunLayer app, you would replace this with Prisma queries and JWT generation.
  • Every response (success, invalid credentials, or blocked) includes rate limit headers so the client always knows its current usage.
  • Save app/api/auth/login/route.ts.

Seeing a TypeScript error in your editor?

  • Make sure you imported authLimiter, getClientIp, and rateLimitHeaders from @/lib/rate-limit. All three must be in the import statement.
  • Check that the path alias @/ is configured in your tsconfig.json. Next.js sets this up by default, but if you customized your config it may be missing.

Why is my auth route showing TypeScript errors?

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { NextRequest } from "next/server";
import { authLimiter, getClientIp, rateLimitHeaders } from "@/lib/rate-limit";

export async function POST(request: NextRequest) {
  const ip = getClientIp(request);

  const { success, limit, remaining, reset } = await authLimiter.limit(ip);

  if (!success) {
    return Response.json(
      {
        error: "Too many login attempts",
        message: "Please wait before trying again.",
        retryAfter: Math.ceil((reset - Date.now()) / 1000),
      },
      {
        status: 429,
        headers: {
          ...rateLimitHeaders(limit, 0, reset),
          "Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      }
    );
  }

  const body = await request.json();
  const { email, password } = body;

  // Simulated auth logic (replace with real Prisma/JWT logic in RunLayer)
  if (email === "runner@runlayer.com" && password === "trailblazer") {
    return Response.json(
      {
        user: { id: "user_123", email, name: "Trail Runner" },
        token: "mock-jwt-token",
      },
      {
        headers: rateLimitHeaders(limit, remaining, reset),
      }
    );
  }

  return Response.json(
    { error: "Invalid credentials" },
    {
      status: 401,
      headers: rateLimitHeaders(limit, remaining, reset),
    }
  );
}
Create the recommendations route

The recommendations endpoint serves personalized gear suggestions. Unlike auth, you want to throttle per user instead of per IP. This way, one user hitting their limit doesn't block other users who share the same corporate IP address.

  • Create the directory for the recommendations route by running:
mkdir -p app/api/recommendations
  • Create a new file called route.ts inside app/api/recommendations/ and add the following code:
import { NextRequest } from "next/server";
import {
  recommendationsLimiter,
  getClientIp,
  rateLimitHeaders,
} from "@/lib/rate-limit";

export async function GET(request: NextRequest) {
  // In production RunLayer, extract user ID from session/JWT
  const userId = request.headers.get("x-user-id");
  const ip = getClientIp(request);

  // Use user ID for authenticated users, IP for anonymous
  const identifier = userId ?? ip;

  const { success, limit, remaining, reset } =
    await recommendationsLimiter.limit(identifier);

  if (!success) {
    return Response.json(
      {
        error: "Rate limit exceeded",
        message: "Too many recommendation requests. Please wait.",
        retryAfter: Math.ceil((reset - Date.now()) / 1000),
      },
      {
        status: 429,
        headers: {
          ...rateLimitHeaders(limit, 0, reset),
          "Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      }
    );
  }

  // Mock recommendations (replace with real Prisma queries in RunLayer)
  const recommendations = [
    {
      id: "gear_001",
      name: "Lightweight Rain Jacket",
      category: "outerwear",
      weather: "rainy",
      temperature: "10-15C",
    },
    {
      id: "gear_002",
      name: "Moisture-Wicking Base Layer",
      category: "tops",
      weather: "cold",
      temperature: "0-10C",
    },
    {
      id: "gear_003",
      name: "Reflective Running Vest",
      category: "accessories",
      weather: "any",
      conditions: "low-light",
    },
  ];

  return Response.json(
    {
      recommendations,
      context: { identifier, weather: "rainy", temp: 12 },
    },
    {
      headers: rateLimitHeaders(limit, remaining, reset),
    }
  );
}

What does this code do?

This endpoint demonstrates user-based rate limiting, where each user has their own independent quota.

  • The x-user-id header simulates an authenticated session. In production RunLayer, you would extract the user ID from a JWT or Prisma session instead.
  • userId ?? ip means authenticated users get tracked by their user ID, while anonymous visitors fall back to IP-based tracking.
  • The sliding window of 10 requests per 60 seconds applies independently to each identifier. User A and User B each get their own 10-request allowance.
  • Save app/api/recommendations/route.ts.

Seeing a module not found error?

  • Verify that recommendationsLimiter is exported from lib/rate-limit.ts. It should be defined with export const.
  • Check that the import path uses @/lib/rate-limit with a forward slash, not a backslash.

Help me fix my recommendations route import error.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { NextRequest } from "next/server";
import {
  recommendationsLimiter,
  getClientIp,
  rateLimitHeaders,
} from "@/lib/rate-limit";

export async function GET(request: NextRequest) {
  // In production RunLayer, extract user ID from session/JWT
  const userId = request.headers.get("x-user-id");
  const ip = getClientIp(request);

  // Use user ID for authenticated users, IP for anonymous
  const identifier = userId ?? ip;

  const { success, limit, remaining, reset } =
    await recommendationsLimiter.limit(identifier);

  if (!success) {
    return Response.json(
      {
        error: "Rate limit exceeded",
        message: "Too many recommendation requests. Please wait.",
        retryAfter: Math.ceil((reset - Date.now()) / 1000),
      },
      {
        status: 429,
        headers: {
          ...rateLimitHeaders(limit, 0, reset),
          "Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      }
    );
  }

  // Mock recommendations (replace with real Prisma queries in RunLayer)
  const recommendations = [
    {
      id: "gear_001",
      name: "Lightweight Rain Jacket",
      category: "outerwear",
      weather: "rainy",
      temperature: "10-15C",
    },
    {
      id: "gear_002",
      name: "Moisture-Wicking Base Layer",
      category: "tops",
      weather: "cold",
      temperature: "0-10C",
    },
    {
      id: "gear_003",
      name: "Reflective Running Vest",
      category: "accessories",
      weather: "any",
      conditions: "low-light",
    },
  ];

  return Response.json(
    {
      recommendations,
      context: { identifier, weather: "rainy", temp: 12 },
    },
    {
      headers: rateLimitHeaders(limit, remaining, reset),
    }
  );
}
Test both endpoints

Both routes are ready. Time to verify that the auth endpoint blocks after 5 attempts and that the recommendations endpoint tracks limits per user independently.

  • Make sure your dev server is still running. If it stopped, restart it by running:
npx next dev
  • Test the auth endpoint by sending 6 rapid login attempts with wrong credentials. Run this command:
for i in $(seq 1 6); do
  echo "Attempt $i: $(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:3000/api/auth/login -H 'Content-Type: application/json' -d '{"email":"attacker@evil.com","password":"wrong"}')"
done

You should see attempts 1 through 5 return 401 (invalid credentials), and attempt 6 return 429 (rate limited).

Why 401 and not 200?

The test uses wrong credentials on purpose. A 401 response means the request passed the rate limit check but failed authentication. This proves the rate limiter lets the first 5 through before blocking the 6th.

  • Test user isolation on the recommendations endpoint. Send requests as two different users by running:
echo "--- Requests as user_alice ---"
for i in $(seq 1 12); do
  response=$(curl -s -o /dev/null -w '%{http_code}' -H 'x-user-id: user_alice' http://localhost:3000/api/recommendations)
  echo "Alice request $i: $response"
done

echo ""
echo "--- Request as user_bob (should NOT be blocked) ---"
response=$(curl -s -o /dev/null -w '%{http_code}' -H 'x-user-id: user_bob' http://localhost:3000/api/recommendations)
echo "Bob request: $response"

Alice's first 10 requests should return 200, and requests 11 and 12 should return 429. Bob's single request should return 200 because his limit is tracked independently.

Why does per-user isolation matter?

If you only tracked by IP, users behind a shared office network would exhaust each other's limits. Tracking by user ID means each authenticated person gets their own full quota regardless of their IP address.

All requests returning 500?

  • Check the terminal where your dev server is running for error messages. A common cause is a missing or malformed .env.local file.
  • Make sure you restarted the dev server after creating the new route files. Next.js should pick them up automatically, but sometimes a restart is needed.
  • If you see the global 429 instead of endpoint-specific blocks, wait 10 seconds for the global limiter to reset. The global limit of 20 requests per 10 seconds may have been hit by your rapid test commands.

Help me debug my rate limiting test results.

Your API now has layered protection. Global limits catch broad abuse in middleware, the auth endpoint blocks brute-force login attacks, and recommendations throttle per user to keep things fair. Next up, you'll build a test script that verifies everything end-to-end.

Test, Verify, and Troubleshoot

Your rate limiters are built, your endpoints are protected, and both the global middleware and per-endpoint handlers are returning proper 429 responses. Now it's time to prove it all works together with an automated test script.

Manually curling endpoints one by one is fine for development, but a repeatable test script lets you verify your entire rate limiting system in seconds. You'll write a bash script that hammers each endpoint, confirms blocks trigger at the right thresholds, and checks that rate limit headers appear on every response.

In this step, get ready to:
  • Create a bash test script that exercises all rate limiting tiers.
  • Troubleshoot common rate limiting issues.
  • Map the rate limiting pattern to RunLayer production endpoints.
Create the test script

A dedicated test script sends a controlled burst of requests to each endpoint, reports which ones pass and which get blocked, and finishes by printing the rate limit headers. This gives you a single command that validates your entire system.

  • Create a scripts folder in your project root by running this command:
mkdir scripts
  • Create a new file called test-rate-limit.sh inside the scripts folder.
  • Paste the following code into scripts/test-rate-limit.sh:
#!/bin/bash
# Test rate limiting for the RunLayer API
# Run with: ./scripts/test-rate-limit.sh

BASE_URL="http://localhost:3000"

echo "=== Testing Global Rate Limit (20 requests per 10s) ==="
echo ""

for i in $(seq 1 25); do
  response=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/recommendations")
  if [ "$response" == "429" ]; then
    echo "Request $i: BLOCKED (429)"
  else
    echo "Request $i: OK ($response)"
  fi
done

echo ""
echo "=== Testing Auth Rate Limit (5 attempts per 60s) ==="
echo ""

for i in $(seq 1 8); do
  response=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/api/auth/login" \
    -H "Content-Type: application/json" \
    -d '{"email":"attacker@evil.com","password":"wrong"}')
  if [ "$response" == "429" ]; then
    echo "Attempt $i: BLOCKED (429)"
  else
    echo "Attempt $i: Response ($response)"
  fi
done

echo ""
echo "=== Testing User Isolation ==="
echo ""

echo "Sending 12 requests as user_alice..."
for i in $(seq 1 12); do
  response=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "x-user-id: user_alice" "$BASE_URL/api/recommendations")
  if [ "$response" == "429" ]; then
    echo "  Alice request $i: BLOCKED (429)"
  fi
done

echo "Sending 1 request as user_bob (should NOT be blocked)..."
response=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "x-user-id: user_bob" "$BASE_URL/api/recommendations")
echo "  Bob request: $response"

echo ""
echo "=== Checking Rate Limit Headers ==="
echo ""

curl -s -D - "$BASE_URL/api/recommendations" 2>/dev/null | grep -i "x-ratelimit\|retry-after\|HTTP/"

What does this script do?

  • Section 1 sends 25 rapid GET requests to /api/recommendations. The global limiter allows 20 per 10 seconds, and the recommendations limiter allows 10 per 60 seconds. You'll see blocks starting around request 11 from the per-endpoint limiter.
  • Section 2 sends 8 POST requests to /api/auth/login with wrong credentials. The auth limiter allows 5 attempts per 60 seconds, so attempts 6-8 get blocked.
  • Section 3 tests user isolation by sending 12 requests as user_alice (triggering her limit), then 1 request as user_bob (which should pass because limits are tracked independently per user).
  • Section 4 displays the raw rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) on a single response.
  • Make the script executable and run it by entering these commands:
chmod +x scripts/test-rate-limit.sh
./scripts/test-rate-limit.sh
  • Save scripts/test-rate-limit.sh.
  • In your terminal output, you should see requests marked as OK for the first several, then BLOCKED (429) once the limits are exceeded. Bob's request should show 200 even after Alice is blocked. The final section should print the X-RateLimit- headers.

Seeing permission denied?

Make sure you ran chmod +x scripts/test-rate-limit.sh before executing the script. Shell scripts need execute permission to run directly.

If you're on Windows, run the script using Git Bash or WSL. The native Command Prompt and PowerShell cannot execute bash scripts.

Help me fix my test script

Wait between test runs

Rate limits persist in Redis across runs. If you re-run the script immediately, you may see blocks earlier than expected because previous requests still count. Wait 60 seconds (the longest window) or restart your Upstash database to clear all keys.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

#!/bin/bash
# Test rate limiting for the RunLayer API
# Run with: ./scripts/test-rate-limit.sh

BASE_URL="http://localhost:3000"

echo "=== Testing Global Rate Limit (20 requests per 10s) ==="
echo ""

for i in $(seq 1 25); do
  response=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/recommendations")
  if [ "$response" == "429" ]; then
    echo "Request $i: BLOCKED (429)"
  else
    echo "Request $i: OK ($response)"
  fi
done

echo ""
echo "=== Testing Auth Rate Limit (5 attempts per 60s) ==="
echo ""

for i in $(seq 1 8); do
  response=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/api/auth/login" \
    -H "Content-Type: application/json" \
    -d '{"email":"attacker@evil.com","password":"wrong"}')
  if [ "$response" == "429" ]; then
    echo "Attempt $i: BLOCKED (429)"
  else
    echo "Attempt $i: Response ($response)"
  fi
done

echo ""
echo "=== Testing User Isolation ==="
echo ""

echo "Sending 12 requests as user_alice..."
for i in $(seq 1 12); do
  response=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "x-user-id: user_alice" "$BASE_URL/api/recommendations")
  if [ "$response" == "429" ]; then
    echo "  Alice request $i: BLOCKED (429)"
  fi
done

echo "Sending 1 request as user_bob (should NOT be blocked)..."
response=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "x-user-id: user_bob" "$BASE_URL/api/recommendations")
echo "  Bob request: $response"

echo ""
echo "=== Checking Rate Limit Headers ==="
echo ""

curl -s -D - "$BASE_URL/api/recommendations" 2>/dev/null | grep -i "x-ratelimit\|retry-after\|HTTP/"
Troubleshoot common issues

If your test script produces unexpected results, here are the most common causes and their fixes.

All requests returning 500?

  • Check that .env.local has both UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN set correctly. A missing or mistyped token causes the Redis client to fail on every request.
  • Restart your dev server after editing .env.local. Next.js only reads environment variables at startup.
  • Verify the values match what Upstash shows on your database details page (no trailing spaces or newlines).

Help me debug 500 errors

Rate limits never triggering?

  • Verify the middleware matcher is /api/:path* in middleware.ts. A typo here means the middleware never runs.
  • Confirm your middleware.ts file is at the project root (next to your app/ directory), not inside app/ or src/.
  • Check that the request path starts with /api/. The script uses http://localhost:3000/api/recommendations which should match.

Help me debug middleware

Headers missing from responses?

  • Confirm the middleware returns NextResponse.next() (from next/server), not a plain Response. Only NextResponse supports setting headers that flow through to the client.
  • Check that you are calling response.headers.set() on the NextResponse.next() object before returning it.

Help me fix missing headers

Map the pattern to RunLayer

Everything you built here is directly portable to your RunLayer running apparel app. The rate limiting module, middleware structure, and per-endpoint pattern all transfer with minimal changes.

How does this map to RunLayer endpoints?

  • Auth/login endpoints get the strict authLimiter (fixed window, 5 attempts per 60 seconds per IP). This prevents brute-force login attacks.
  • Recommendations and weather endpoints get the recommendationsLimiter (sliding window, 10 requests per 60 seconds per user). This ensures fair access to expensive API calls.
  • Admin and gear-management endpoints should get their own admin-specific limiter with higher thresholds, since admin users need more headroom.
  • The globalLimiter stays in middleware and catches broad abuse before any route handler runs.

In production, replace the x-user-id header with real user identity extracted from your Prisma session or JWT. The rate limiter only needs a unique string identifier per user.

Secret mission

Token Bucket Burst Tolerance and Admin Analytics

Your current sliding window limiter penalizes users who browse quickly. Switch to a token bucket algorithm that tolerates short bursts, and build an admin endpoint that lets you inspect rate limit state directly in Redis.

Clean Up Your Resources

Clean Up Your Resources

This project uses the Upstash Redis free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Upstash Redis database (runlayer-ratelimit, hosted on Upstash free tier).
  • Upstash account (free, no credit card required).
  • Local project files (lib/rate-limit.ts, middleware.ts, app/api/ routes, scripts/test-rate-limit.sh, .env.local).
  • npm packages (@upstash/ratelimit and @upstash/redis, installed locally in your project).

Keep everything running

No action needed. Choose this if you are still actively building or plan to integrate rate limiting into your RunLayer app soon.

  • Your Upstash Redis database stays active on the free tier at no cost.
  • Free tier databases are archived after a minimum of 30 days of inactivity. Upstash sends warning emails before archival and takes a backup of your data automatically.
  • Your local project files and npm packages remain on disk.

Pause - I'll come back to this later

Stop the development server to free up your terminal. Your Upstash database remains available on the free tier with no cost.

  • In your terminal, press Ctrl+C to stop the Next.js development server.
  • Your Upstash Redis database stays on the free tier. No charges accrue while idle.
  • When you return, run next dev from your project folder to pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your rate limiting setup.

  • Delete the Upstash Redis database: go to console.upstash.com, select your runlayer-ratelimit database, scroll to the bottom of the Details page, click Delete this database, and follow the confirmation prompts.
  • Delete your Upstash account (optional): first delete all databases, then navigate to Account, then Settings, then click Delete Account. This removes all data and payment information permanently.
  • Delete your local project files by running the following command from the folder that contains your project directory:

macOS

rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]

Windows

rmdir /s /q [[PROJECT_FOLDER="your-project-folder-name"]]

What about the npm packages?

The @upstash/ratelimit and @upstash/redis packages live inside your project's node_modules folder. Deleting the project folder removes them automatically.

Nice Work!

Nice Work!

Nice work! You just built a production-ready, distributed rate limiting system that protects API routes from abuse while keeping legitimate users unaffected.

You've learned how to:

  • Build a reusable rate limiter module with multiple algorithms (sliding window, fixed window) backed by Upstash Redis, and implement global IP-based rate limiting via Next.js middleware.
  • Add per-endpoint protection with strict brute-force limits on auth and user-based limits on recommendations, returning proper HTTP 429 responses with standard rate limit headers.
  • Create a test script that verifies rate limits trigger at the expected thresholds and document how to integrate this pattern into your RunLayer app.
  • Secret Mission: Switched to token bucket for burst-tolerant recommendation limits and built an admin analytics endpoint for inspecting rate limit state in Redis.

Ready to quiz yourself?