How Indie Founders Ship Rate Limiting for Micro SaaS APIs Before the First Abuse Incident
Most micro SaaS APIs ship without rate limits until a bot or misbehaving client runs up the bill. Here is the 45-minute setup that stops that.
You can protect a micro SaaS API from the most common abuse with Upstash rate limiting in under an hour, and the free tier covers most indie products until they have real traffic. The setup uses a sliding-window counter in Redis that works across serverless functions, Edge runtimes, and Cloudflare Workers without managing a server.
Most founders only add rate limits after a bot fills their database, a misconfigured client hammers an AI endpoint, or a script runs up an unexpected OpenAI bill. By then the damage is already done. The practical move is to ship the limit on day one for any public or expensive route.
Why rate limiting is the first control for indie APIs
Rate limiting does three jobs for a solo product:
- Stops credential-stuffing and brute-force attacks on login and password-reset routes.
- Caps the cost of AI or third-party API calls that a single user or script can trigger.
- Prevents one misbehaving integration or crawler from degrading the experience for everyone else.
In-memory limits (the default in many Node frameworks) fail as soon as you scale past one process. Cloudflare rate-limiting rules are excellent for broad traffic but harder to key on a logged-in user ID without extra setup. Upstash Redis + @upstash/ratelimit solves both: it is HTTP-based, works in Edge middleware, and has a free tier of 500k commands per month.
The 45-minute setup for Next.js (or any serverless route)
1. Create the Redis database
Sign up at Upstash, create a free Redis database, and copy the UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN into your environment variables. No VPC or connection pooling is required.
2. Install the libraries
``bash npm install @upstash/ratelimit @upstash/redis ``
3. Create reusable limiters
In a file such as lib/rate-limit.ts:
```ts import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
export const authLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "15 m"), prefix: "rl:auth", analytics: true, });
export const apiLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(60, "1 m"), prefix: "rl:api", });
export const aiLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, "1 h"), prefix: "rl:ai", }); ```
Sliding window is the better default for most SaaS. It avoids the burst-then-wait pattern that fixed windows allow at the boundary.
4. Apply it in middleware or route handlers
For Next.js App Router middleware:
```ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { apiLimiter } from "@/lib/rate-limit";
export async function middleware(request: NextRequest) { const ip = request.ip ?? "127.0.0.1"; const { success, remaining, reset } = await apiLimiter.limit(ip);
if (!success) { return NextResponse.json( { error: "Too many requests" }, { status: 429, headers: { "X-RateLimit-Remaining": remaining.toString(), "X-RateLimit-Reset": reset.toString(), "Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(), }, } ); }
return NextResponse.next(); } ```
For authenticated routes, key on user.id instead of IP so one aggressive user does not affect others on the same network.
Different limits by endpoint type
| Endpoint type | Suggested limit | Key | Reason | |---------------|-----------------|-----|--------| | Login / signup / reset | 5 / 15 min | IP + email | Brute force | | Public API (unauthenticated) | 30 / min | IP | Scrapers and bots | | Authenticated CRUD | 60–120 / min | User ID | Normal product use | | AI / expensive third-party | 10–30 / hour | User ID | Cost control | | Webhooks inbound | Higher or none | Signature only | Expected volume |
Start tighter than you think you need. You can raise the numbers later from the Upstash console or by changing the code and redeploying. Dynamic limits (available in recent @upstash/ratelimit versions) let you adjust a global cap at runtime without a deploy.
Protecting AI endpoints that cost real money
If your product proxies OpenAI, Anthropic, or any metered API, rate limiting is not optional. A single script can generate thousands of dollars of usage in minutes.
Two additional controls that take another 20 minutes:
- Per-user daily token or request budget stored in the same Redis instance (or in your Postgres table).
- Hard spending caps set at the AI provider dashboard (OpenAI and Anthropic both support them).
Log every AI call with the user ID, token count, and model. When a user hits the limit, return a clear message and offer an upgrade path rather than a silent 429. This turns an abuse control into a conversion moment.
Monitoring and what to do when limits hit
Upstash analytics (enabled with the analytics: true flag) shows you which identifiers are hitting limits. Watch for:
- Sudden spikes from a single IP or user ID.
- Repeated 429s on auth routes (possible credential stuffing).
- AI routes approaching their hourly cap early in the day.
When you see abuse, the response is usually:
- Temporarily tighten the limit for that identifier.
- Block the IP at Cloudflare if it is clearly malicious.
- Contact the user if it looks like a legitimate integration gone wrong.
Do not disable the limiter globally. Fix the specific actor.
Common mistakes and when to upgrade
- Using only IP-based limits on authenticated routes. A shared office or mobile carrier will collide.
- Setting limits so high they never trigger. Test by scripting 100 requests and confirming the 429.
- Forgetting the
Retry-Afterheader. Legitimate clients need it to back off cleanly. - Ignoring cost-based limits on AI routes. Request count is not the same as dollar cost.
The free Upstash tier is enough for most micro SaaS until you are past a few thousand daily active users. When command volume grows, the pay-as-you-go plan is still inexpensive relative to the incidents it prevents. At that point you can also layer Cloudflare rate-limiting rules for the broadest traffic and keep Upstash for user-specific logic.
Ship the limiter on the routes that can hurt you first: authentication and any call that spends money. The rest can follow. An hour of setup now is cheaper than an unexpected bill or a locked-out customer later.
How to test the limits before you ship
Do not deploy rate limits without verifying they work. The fastest way is a short shell loop against a staging URL:
``bash for i in {1..70}; do curl -s -o /dev/null -w "%{http_code}\n" https://staging.yourapp.com/api/health done ``
You should see a string of 200s followed by 429s once the window is exceeded. Check the response headers for X-RateLimit-Remaining and Retry-After. If the 429 never appears, the limiter is not in the request path (common when middleware matcher excludes the route or the Redis env vars are missing).
For authenticated routes, create two test users and confirm that one user’s rapid requests do not affect the other. This is the main reason to key on user ID rather than IP.
Handling 429 responses in your own clients
If you ship a public API or an official SDK, document the rate-limit headers and teach clients to honor them. A simple exponential back-off on 429 is enough for most integrations:
``ts async function fetchWithRetry(url: string, options: RequestInit, retries = 3) { const res = await fetch(url, options); if (res.status === 429 && retries > 0) { const retryAfter = parseInt(res.headers.get("Retry-After") || "5", 10); await new Promise(r => setTimeout(r, retryAfter * 1000)); return fetchWithRetry(url, options, retries - 1); } return res; } ``
Tell users in the API docs exactly which endpoints are limited and what the numbers are. Surprise 429s generate support tickets; documented ones do not.
When Cloudflare rate limiting is the better first layer
If your product already sits behind Cloudflare (most indie SaaS on Vercel or similar do), the free or Pro rate-limiting rules can stop broad volumetric attacks before they reach your functions. Use Cloudflare for:
- Per-IP limits on the entire domain or on
/api/*. - Challenge or block actions for obvious bots.
Keep Upstash for the finer-grained, user-aware limits that Cloudflare rules cannot easily express without Workers. The combination is cheap and effective: Cloudflare absorbs the spray, Upstash protects the expensive or sensitive routes.
Cost and operational notes for solo founders
On the free Upstash tier the only number that matters is Redis commands. Each limit() call is one command. At 500k commands per month you can handle roughly 15k–20k limited requests per day before you start paying. Most micro SaaS stay under that for a long time.
When you do outgrow the free tier, the pay-as-you-go plan bills $0.20 per 100k commands. A product doing 1M limited requests per month costs about $2. That is still cheaper than a single unexpected AI bill or a day of downtime.
Store the Upstash credentials in your platform’s secret manager (Vercel env, Cloudflare secrets, etc.). Rotate them if you ever suspect a leak. Because the library is HTTP-based there is no persistent connection to manage and no risk of connection exhaustion under load.
Checklist before you mark rate limiting done
- [ ] Auth routes limited by IP + identifier (email or user ID).
- [ ] AI or metered routes limited by user ID with a tighter window.
- [ ] Public unauthenticated routes limited by IP.
- [ ] 429 responses include
Retry-Afterand clear error JSON. - [ ] Staging test confirmed the limit actually fires.
- [ ] Analytics enabled so you can see which keys are hitting limits.
- [ ] Hard spending caps set at any AI provider you proxy.
- [ ] Docs updated if you expose a public API.
Rate limiting is not the whole security story. You still need proper authentication, authorization checks on every object (no broken object-level access), input validation, and secret hygiene. It is, however, the control that stops the most common and most expensive incidents for a one-person product. Ship it early, keep the numbers conservative, and raise them only when real usage data shows you can.