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.
If your SaaS has any of these, you need rate limiting today:
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 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.
Different actions need different limits. Here is a practical starting point:
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.
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.
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.
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.
The sliding window pattern works for most early-stage products. You will want to move to Redis or Upstash when:
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.
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.