Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

How to Rate Limit Your Next.js SaaS API -- Stop Abuse Without Adding Redis

September 18, 2026
nextjsneon-dbsaassecuritydrizzle-orm

Most API endpoints in a new SaaS are unprotected -- not because founders are reckless, but because rate limiting feels like something you add later. Then a bot scrapes your database, a frustrated user hammers the Resend email button 200 times, or a competitor starts hitting your AI feature from a script. You find out from an alert, not a plan.

The good news: you do not need Redis, Upstash, or any paid external service to add meaningful rate limiting to a Next.js SaaS boilerplate. Your Neon database is already there. This post shows you how to use it.

Who needs this right now

If your SaaS has any of these, you need rate limiting today:

  • A registration or login endpoint
  • A send-email or resend-code button
  • An AI feature that calls Claude (every call costs real money)
  • A public search or lookup endpoint
  • Any action that writes to the database

Rate limiting does not mean blocking good users. It means adding a speed bump that stops abuse without affecting normal usage. A real user who resets their password twice in an hour is fine. A script that calls your reset endpoint 500 times in a minute is not.

The approach: a sliding window in Postgres

The standard advice is to use Redis because Redis is fast and atomic. But Neon is also fast, and for most early-stage SaaS products, Postgres is more than enough.

The pattern is called a sliding window counter. You store a timestamp for each request in a table, delete entries older than the window, and count what is left. If the count exceeds your limit, you reject the request.

Here is the Drizzle schema:

export const rateLimitTable = pgTable('rate_limit_events', {
  id: uuid('id').primaryKey().defaultRandom(),
  key: text('key').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});

Add an index on (key, created_at) -- without it, every check scans the full table.

The check itself is one function in your service layer:

async function checkRateLimit(
  key: string,
  limit: number,
  windowSeconds: number
): Promise<boolean> {
  const windowStart = new Date(Date.now() - windowSeconds * 1000);
 
  await db.delete(rateLimitTable).where(
    and(eq(rateLimitTable.key, key), lt(rateLimitTable.createdAt, windowStart))
  );
 
  const [{ count }] = await db
    .select({ count: sql`COUNT(*)` })
    .from(rateLimitTable)
    .where(and(eq(rateLimitTable.key, key), gte(rateLimitTable.createdAt, windowStart)));
 
  if (Number(count) >= limit) return false;
 
  await db.insert(rateLimitTable).values({ key });
  return true;
}

Call it at the top of any route handler, before the expensive operation.

Where to set the limits

Different actions need different limits. Here is a practical starting point:

  • Login attempts: 5 per 15 minutes per IP address
  • Password reset requests: 3 per hour per email address
  • Email verification resend: 3 per hour per user
  • AI chat messages: 5 per minute per user (burst protection on top of your credit system)
  • Public search: 60 per minute per IP

The key decision: use the user ID for authenticated routes and the IP address for unauthenticated ones. Your API already has both -- user ID from JWT via getUserFromRequest, and IP from request.headers.get('x-forwarded-for') on Vercel.

For unauthenticated routes, the key looks like reset-password:ip:1.2.3.4. For authenticated routes: ai-chat:user:abc123.

Protecting your AI credits specifically

If your SaaS has an AI feature, rate limiting is not optional -- it is financial protection. One runaway script or a bug in a client app can drain a month of credits in minutes.

The boilerplate already deducts credits before calling Claude and returns 402 when the balance hits zero. Add a burst limit on top:

const allowed = await checkRateLimit(`ai-chat:${userId}`, 5, 60);
if (!allowed) throw new HttpError(429, 'Slow down -- you are sending messages too fast.');

Five messages per minute is generous for any human. It is invisible to a real user and a hard ceiling for broken scripts.

What to return when a limit fires

Return 429 Too Many Requests with a Retry-After header. Tell the user how long to wait, not just that they are blocked:

return NextResponse.json(
  { error: 'Too many requests. Please wait before trying again.' },
  { status: 429, headers: { 'Retry-After': '60' } }
);

This is the difference between a rate limit that feels like a wall and one that feels like a guardrail. A real user who sees "try again in 60 seconds" will wait. A script will not, and that is exactly the point.

Keeping the table small

The cleanup step runs on every check, which keeps the rate_limit_events table from growing unbounded for active keys. For a complete sweep, add a Vercel Cron job that runs once a day:

// app/api/cron/cleanup-rate-limits/route.ts
export async function GET() {
  const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
  await db.delete(rateLimitTable).where(lt(rateLimitTable.createdAt, cutoff));
  return NextResponse.json({ ok: true });
}

Add it to vercel.json under crons and point it at that route.

When the Postgres approach stops being enough

The sliding window pattern works for most early-stage products. You will want to move to Redis or Upstash when:

  • You have more than ~50,000 active users making frequent API calls
  • You run multiple servers and need distributed, atomic counters
  • Your rate limits are sub-second (Postgres cannot guarantee atomicity under heavy concurrent load)

Until then, your Neon database is enough. Avoid adding an external service before you need it -- every new dependency is one more thing that can break, go down, and cost money.

What to do next

Rate limiting is one of the items on the Next.js SaaS security checklist that is easy to skip and painful to add after the fact. Run through that list before your first real user signs up.

If you are starting fresh, the Next.js SaaS boilerplate already has the auth layer, getUserFromRequest, and HttpError wired up -- you add the table, the migration, and the check function. Start with your AI and login endpoints, then work outward.

Protect your endpoints before you need to, not after you find out you should have.