How to add Free vs Pro usage limits to an AI chatbot in Next.js
By VibeBoot ·
Short on time? The AI Chatbot Starter already has this wired up: $59 one-time, 14-day money-back guarantee.
Every AI chatbot with a free plan needs limits, and they have to live on the server. A message counter in React state stops nobody: anyone can open DevTools or call your API with curl, and each request costs you real money at your model provider. This guide shows a pattern that holds up in a Next.js App Router app: one file that defines the plans, a per-day usage row in the database, an atomic "reserve a message" step that two browser tabs can't race past, and an API route that checks everything before it calls the model. It also has the prompts that get Cursor or Claude Code to build it without cutting corners.
What you're protecting against
- Cost. A free user who sends 2,000 messages a day can burn more in API fees than a Pro user pays you in a month.
- Abuse. Scripts that hammer
/api/chatin a loop, or paste a whole book into one message. - Race conditions. Two tabs sending at the same moment, each reading "19 of 20 used" and both getting through.
- Unpaid upgrades. A user who flips a flag in the browser to unlock Pro features.
The rule that solves all four: the browser can display limits, but only the server enforces them.
Step 1: put every limit in one file
When your agent scatters if (count > 20) across components and routes, the numbers drift apart. Keep one source of truth and let deployments override it with environment variables.
// src/lib/plans.ts
export type PlanId = "free" | "pro";
function envInt(name: string, fallback: number) {
const n = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export function getLimits(plan: PlanId) {
return plan === "pro"
? { messagesPerDay: envInt("PRO_DAILY_MESSAGES", 500), maxInputChars: envInt("PRO_MAX_INPUT_CHARS", 24_000), contextMessages: 40 }
: { messagesPerDay: envInt("FREE_DAILY_MESSAGES", 20), maxInputChars: envInt("FREE_MAX_INPUT_CHARS", 4_000), contextMessages: 12 };
}Three limits cover most of the cost: messages per day, characters per message, and how many earlier messages you send to the model as context. That last one matters more than people expect, because long conversations resend their whole history on every turn. To pick the actual numbers, plug your model, token counts and Pro price into our free AI chatbot cost & pricing calculator: it shows the cost per message, your Pro margin after Stripe fees if everyone hits the cap, and a free-tier cap that fits your budget.
Step 2: decide the plan from your database, not the client
The user's plan comes from the subscription status your Stripe webhook wrote to the database. Never from a request body, a cookie the client can edit, or the checkout success page.
export function isSubscribed(status?: string | null) {
return status === "active" || status === "trialing";
}
export function planFor(user: { subscriptionStatus?: string | null } | null) {
return isSubscribed(user?.subscriptionStatus) ? "pro" : "free";
}If you haven't wired the webhook yet, start with our guide to Stripe subscriptions in Next.js with Cursor.
Step 3: one usage row per user per day
Store usage as a row keyed by user and UTC day. That gives you a daily reset for free (tomorrow is a new row) and a history you can chart later.
model Usage {
id String @id @default(cuid())
userId String
day String // YYYY-MM-DD (UTC)
messages Int @default(0)
tokens Int @default(0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, day])
}Step 4: reserve the message atomically
This is the step AI agents usually get wrong. The obvious version reads the count, compares it with the limit, then writes count + 1. Two requests that arrive together both read 19 and both pass. Instead, increment first, then check, then roll back if you went over. The database increment is atomic, so only one request can land on number 20.
// src/lib/usage.ts
export async function reserveMessage(db, userId: string, plan: PlanId) {
const day = new Date().toISOString().slice(0, 10);
const row = await db.usage.upsert({
where: { userId_day: { userId, day } },
create: { userId, day, messages: 1 },
update: { messages: { increment: 1 } },
});
const { messagesPerDay } = getLimits(plan);
if (row.messages > messagesPerDay) {
await db.usage.update({ where: { userId_day: { userId, day } }, data: { messages: { decrement: 1 } } });
return { ok: false, status: 429, code: "daily_limit" };
}
return { ok: true };
}Keep the checks themselves (checkInput, checkDaily) as pure functions that take numbers and return a result. They're trivial to unit test, and the tests stop a future agent edit from quietly loosening a limit.
Step 5: check in the right order in the API route
Order matters, because each check is cheaper than the one after it and you never want to call the model for a request you'll reject.
- Auth. No session, return 401.
- Burst rate limit. For example 12 requests a minute per user, return 429 with a
Retry-Afterheader. - Validate the body with a schema such as Zod, so a malformed request never reaches your logic.
- Authorization. Load the conversation with the user's id in the query, so nobody can read or write someone else's chat by guessing an id. Check Pro-only features (like a premium persona or model) here too.
- Input size. Reject messages over
maxInputCharswith a 413. - Daily quota. Call
reserveMessage. - Only now call the model, sending at most
contextMessagesof history.
Return a stable error code (daily_limit, input_too_long, pro_only) alongside the human message. The chat UI can then show an upgrade button for exactly those codes instead of parsing error text.
One caveat: a simple in-memory rate limiter counts per server instance. On serverless or multi-instance hosting, back it with a shared store such as Redis or Cloudflare's rate limiting for hard guarantees. The daily quota is already safe because it lives in your database.
Step 6: track tokens, but don't bill from estimates
After the stream finishes, add an approximate token count to the same usage row (about 4 characters per token works for dashboards). If you ever charge per token, use the usage numbers your model provider returns, not an estimate.
Prompts that get Cursor to build this correctly
Paste these one at a time and review the diff after each:
- "Create
src/lib/plans.tsas the only place that defines Free and Pro limits: messages per day, max input characters and context messages, each overridable by an env var. Don't hard-code limits anywhere else." - "Add a Prisma
Usagemodel with a unique(userId, day)key. WritereserveMessageso it increments first, then checks the limit, then decrements if over. Explain why a read-then-write version is unsafe." - "In
/api/chat, enforce in this order: auth, burst rate limit, Zod validation, conversation ownership scoped by userId in the query, input size, daily quota, then the model call. Return JSON errors with a stablecode." - "Write Vitest tests for the pure limit checks, including the exact boundary (message 20 passes, 21 fails) and a test that two concurrent reservations at 19 can't both succeed."
A project rule that says "limits live in src/lib/plans.ts and are enforced only on the server" keeps future edits honest. Our free Cursor rules generator builds one for your stack.
Want it already built?
The VibeBoot AI Chatbot Starter ships this exact pattern: plan limits in one file, atomic daily quotas, a burst rate limiter, ownership-scoped conversations, Pro-only personas, Stripe subscriptions and an upgrade prompt wired to the error codes, plus 37 Vitest tests. You can try the live demo and hit the Free limit yourself, no API key needed. If you're still choosing a base, our AI chatbot template buyer's guide compares it with the free and paid alternatives.