Back to Blog

Rate limiting in Next.js: protecting your SaaS API from abuse

Rate Limiting
Security
Next.js
API Protection
Performance
Best Practices

Rate limiting is essential for protecting your Next.js SaaS API from abuse, DDoS attacks, and resource exhaustion. This guide covers database-based rate limiting, Redis solutions, middleware patterns, and implementation strategies used in production SaaS applications like Fastack.

Why rate limiting matters

Without rate limiting, your API is vulnerable to:

  • Brute force attacks - Attackers can attempt unlimited login attempts
  • Resource exhaustion - Excessive requests can overwhelm your server
  • Email spam - Unrestricted email sending can lead to abuse
  • API abuse - Malicious users can consume your API quota
  • Cost escalation - Uncontrolled API usage increases infrastructure costs

Fastack implements rate limiting in critical authentication and email endpoints to prevent abuse while maintaining a good user experience.

Database-based rate limiting

For SaaS applications using Prisma, database-based rate limiting is straightforward and doesn't require additional infrastructure. Fastack uses this approach for email verification and registration endpoints:

Time-based cooldown pattern

Check the timestamp of the most recent action to enforce a cooldown period:

Database-based rate limiting example
// packages/auth/api/resend-verification.ts
export async function resendVerificationHandler(request: Request) {
  const { email } = await request.json();
  
  // Rate limiting: 60 seconds between resends
  const RESEND_COOLDOWN_MS = 60000; // 60 seconds
  
  // Check for most recent verification token to enforce rate limiting
  const mostRecentToken = await prisma.verificationToken.findFirst({
    where: { identifier: email },
    orderBy: { createdAt: 'desc' },
  });
  
  if (mostRecentToken && mostRecentToken.createdAt) {
    const timeSinceLastResend = Date.now() - mostRecentToken.createdAt.getTime();
    
    if (timeSinceLastResend < RESEND_COOLDOWN_MS) {
      const remainingSeconds = Math.ceil(
        (RESEND_COOLDOWN_MS - timeSinceLastResend) / 1000
      );
      
      return NextResponse.json(
        {
          error: `Please wait ${remainingSeconds} seconds before requesting another verification email.`,
        },
        { status: 429 } // Too Many Requests
      );
    }
  }
  
  // Proceed with sending verification email
  // ...
}

This pattern uses existing database records (verification tokens) to track the last action timestamp, making it simple to implement without additional infrastructure. Fastack uses this approach for email verification endpoints where the verification token table already exists.

Advantages of database rate limiting

  • No additional infrastructure required (uses existing database)
  • Persistent across server restarts
  • Works well for user-specific rate limits (per email, per user ID)
  • Easy to audit and debug (data persists in database)

Database-based rate limiting is ideal for SaaS applications that already use Prisma, as it leverages existing infrastructure and provides persistent rate limit tracking.

Redis-based rate limiting

For high-traffic applications or when you need distributed rate limiting across multiple servers, Redis provides fast, in-memory rate limiting:

Sliding window rate limiter

Implement a sliding window algorithm using Redis:

Redis rate limiting implementation
// lib/rate-limit.ts
import { Redis } from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

interface RateLimitOptions {
  identifier: string; // IP address, user ID, or email
  limit: number; // Maximum requests
  window: number; // Time window in seconds
}

export async function checkRateLimit(
  options: RateLimitOptions
): Promise<{ allowed: boolean; remaining: number; reset: number }> {
  const { identifier, limit, window } = options;
  const key = `rate-limit:${identifier}`;
  const now = Date.now();
  const windowStart = now - window * 1000;

  // Remove old entries outside the window
  await redis.zremrangebyscore(key, 0, windowStart);

  // Count current requests in the window
  const count = await redis.zcard(key);

  if (count >= limit) {
    // Get the oldest request timestamp to calculate reset time
    const oldest = await redis.zrange(key, 0, 0, 'WITHSCORES');
    const reset = oldest.length > 0 
      ? parseInt(oldest[1]) + window * 1000 
      : now + window * 1000;

    return {
      allowed: false,
      remaining: 0,
      reset,
    };
  }

  // Add current request
  await redis.zadd(key, now, `${now}-${Math.random()}`);
  await redis.expire(key, window);

  return {
    allowed: true,
    remaining: limit - count - 1,
    reset: now + window * 1000,
  };
}

This sliding window implementation uses Redis sorted sets to track requests within a time window, providing accurate rate limiting that works across multiple server instances.

Using rate limiting in API routes

Apply rate limiting to your API routes:

API route with rate limiting
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { checkRateLimit } from '@/lib/rate-limit';

export async function POST(request: NextRequest) {
  // Get client identifier (IP address or user ID)
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 
             request.headers.get('x-real-ip') || 
             'unknown';

  // Check rate limit: 5 requests per 15 minutes
  const rateLimit = await checkRateLimit({
    identifier: `login:${ip}`,
    limit: 5,
    window: 900, // 15 minutes
  });

  if (!rateLimit.allowed) {
    const resetDate = new Date(rateLimit.reset);
    return NextResponse.json(
      {
        error: 'Too many login attempts. Please try again later.',
        reset: resetDate.toISOString(),
      },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': '5',
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': resetDate.toISOString(),
          'Retry-After': Math.ceil((rateLimit.reset - Date.now()) / 1000).toString(),
        },
      }
    );
  }

  // Proceed with login logic
  // ...

  // Return rate limit headers
  return NextResponse.json(
    { success: true },
    {
      headers: {
        'X-RateLimit-Limit': '5',
        'X-RateLimit-Remaining': rateLimit.remaining.toString(),
        'X-RateLimit-Reset': new Date(rateLimit.reset).toISOString(),
      },
    }
  );
}

Including rate limit headers (X-RateLimit-*) helps clients understand their rate limit status and when they can make additional requests.

Rate limiting middleware

Create reusable rate limiting middleware for Next.js API routes:

Rate limiting middleware
// lib/middleware/rate-limit.ts
import { NextRequest, NextResponse } from 'next/server';
import { checkRateLimit } from '../rate-limit';

interface RateLimitConfig {
  limit: number;
  window: number; // in seconds
  identifier?: (request: NextRequest) => string;
}

export function createRateLimitMiddleware(config: RateLimitConfig) {
  return async (request: NextRequest): Promise<NextResponse | null> => {
    // Get identifier (defaults to IP address)
    const identifier = config.identifier 
      ? config.identifier(request)
      : request.headers.get('x-forwarded-for')?.split(',')[0] || 
        request.headers.get('x-real-ip') || 
        'unknown';

    const rateLimit = await checkRateLimit({
      identifier: `${request.nextUrl.pathname}:${identifier}`,
      limit: config.limit,
      window: config.window,
    });

    if (!rateLimit.allowed) {
      const resetDate = new Date(rateLimit.reset);
      return NextResponse.json(
        { error: 'Too many requests. Please try again later.' },
        {
          status: 429,
          headers: {
            'Retry-After': Math.ceil((rateLimit.reset - Date.now()) / 1000).toString(),
            'X-RateLimit-Limit': config.limit.toString(),
            'X-RateLimit-Remaining': '0',
            'X-RateLimit-Reset': resetDate.toISOString(),
          },
        }
      );
    }

    // Rate limit passed, return null to continue
    return null;
  };
}

// Usage in API route
const rateLimit = createRateLimitMiddleware({
  limit: 10,
  window: 60, // 10 requests per minute
});

export async function POST(request: NextRequest) {
  const rateLimitResponse = await rateLimit(request);
  if (rateLimitResponse) {
    return rateLimitResponse;
  }

  // Your API logic here
  return NextResponse.json({ success: true });
}

This middleware pattern allows you to apply rate limiting consistently across multiple API routes with different limits and windows.

Rate limiting strategies

Different endpoints require different rate limiting approaches:

Authentication endpoints

  • Login attempts - 5 attempts per 15 minutes per IP
  • Password reset - 3 requests per hour per email
  • Email verification - 1 request per 60 seconds per email (as implemented in Fastack)

API endpoints

  • Public APIs - 100 requests per minute per IP
  • Authenticated APIs - 1000 requests per minute per user
  • Write operations - Stricter limits (e.g., 10 requests per minute)

Resource-intensive endpoints

  • File uploads - 10 uploads per hour per user
  • Email sending - 5 emails per minute per user
  • Data exports - 1 export per hour per user

Identifying clients for rate limiting

Choose the right identifier based on your use case:

IP address

Best for protecting against brute force attacks and anonymous abuse:

IP-based rate limiting
function getClientIP(request: NextRequest): string {
  // Check X-Forwarded-For header (from proxies/load balancers)
  const forwarded = request.headers.get('x-forwarded-for');
  if (forwarded) {
    // Take the first IP (original client)
    return forwarded.split(',')[0].trim();
  }
  
  // Fallback to X-Real-IP
  const realIP = request.headers.get('x-real-ip');
  if (realIP) {
    return realIP;
  }
  
  // Last resort: use connection remote address
  return request.ip || 'unknown';
}

// Use in rate limiting
const identifier = `login:${getClientIP(request)}`;

Note: IP-based rate limiting can be bypassed with VPNs or proxies, but it's effective for preventing automated attacks.

User ID or email

For authenticated endpoints, rate limit by user:

User-based rate limiting
import { getToken } from 'next-auth/jwt';

export async function POST(request: NextRequest) {
  const token = await getToken({ req: request });
  
  if (!token) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Rate limit by user ID
  const rateLimit = await checkRateLimit({
    identifier: `api:${token.id}`,
    limit: 1000,
    window: 60, // 1000 requests per minute per user
  });

  // ...
}

User-based rate limiting is more accurate for authenticated endpoints and prevents legitimate users from being blocked by shared IP addresses.

API keys

For API endpoints, rate limit by API key to provide different tiers:

API key-based rate limiting
export async function POST(request: NextRequest) {
  const apiKey = request.headers.get('x-api-key');
  
  if (!apiKey) {
    return NextResponse.json({ error: 'API key required' }, { status: 401 });
  }

  // Look up API key and get tier limits
  const keyData = await prisma.apiKey.findUnique({
    where: { key: apiKey },
    include: { tier: true },
  });

  if (!keyData) {
    return NextResponse.json({ error: 'Invalid API key' }, { status: 401 });
  }

  // Apply tier-specific rate limits
  const rateLimit = await checkRateLimit({
    identifier: `api:${apiKey}`,
    limit: keyData.tier.requestsPerMinute,
    window: 60,
  });

  // ...
}

Best practices

Follow these practices for effective rate limiting:

  • Return proper HTTP status codes - Use 429 (Too Many Requests) for rate limit violations
  • Include rate limit headers - X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
  • Set Retry-After header - Tell clients when they can retry
  • Log rate limit violations - Monitor for abuse patterns
  • Use appropriate limits - Balance security with user experience
  • Whitelist trusted IPs - Allow internal services or monitoring tools

Rate limiting in Fastack

Fastack implements database-based rate limiting for email verification endpoints, using the verification token table to track cooldown periods. This approach:

  • Leverages existing Prisma schema (no additional tables needed)
  • Provides persistent rate limiting across server restarts
  • Enforces per-email rate limits (60 seconds between verification email requests)
  • Returns user-friendly error messages with remaining wait time

For high-traffic applications, you can extend Fastack's rate limiting by adding Redis-based solutions for distributed rate limiting across multiple server instances.

Conclusion

Rate limiting is essential for protecting your Next.js SaaS API from abuse and ensuring fair resource usage. Database-based rate limiting works well for most SaaS applications, while Redis provides better performance for high-traffic scenarios.

Fastack's implementation demonstrates how to use existing database records for rate limiting, making it easy to add protection without additional infrastructure. For production applications, consider implementing rate limiting on all public-facing endpoints, especially authentication, email sending, and resource-intensive operations.

Ready to build your SaaS faster?

Get our scalable, production-ready boilerplate to save endless hours of development and setup

Rate Limiting in Next.js: Protecting Your SaaS API from Abuse - Fastack