You added Stripe subscriptions. Users can check out, manage their plan, and cancel. But your app still lets everyone use every feature regardless of what they pay.
That is the gap this post closes.
A plan-gating pattern with three parts:
plan column on your users table that Stripe webhooks keep in syncNo middleware. No third-party library. The check lives where business logic belongs -- in the service layer.
Add a plan column and Stripe identifiers to your userTable:
// modules/user/user.schema.ts
export const planEnum = pgEnum("plan", ["free", "pro", "enterprise"]);
export const userTable = pgTable("users", {
// ... existing columns
plan: planEnum("plan").notNull().default("free"),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
});
Run npm run db:generate && npm run db:migrate after this change.
A single config object is easier to maintain than scattered if/else blocks:
// lib/plans.ts
export const PLAN_LIMITS = {
free: { projects: 3, teamMembers: 1, aiCredits: 50 },
pro: { projects: 20, teamMembers: 5, aiCredits: 500 },
enterprise: { projects: Infinity, teamMembers: Infinity, aiCredits: 5000 },
} as const;
export type Plan = keyof typeof PLAN_LIMITS;
export function getLimits(plan: Plan) {
return PLAN_LIMITS[plan];
}
export function resolvePlan(priceId: string): Plan {
if (priceId === process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO) return "pro";
if (priceId === process.env.NEXT_PUBLIC_STRIPE_PRICE_ENTERPRISE) return "enterprise";
return "free";
}
When you change a limit, you change one line. No logic scattered across multiple files.
Check usage before inserting -- never let the resource grow unbounded and check after the fact:
// modules/project/project.service.ts
import { getLimits } from "@/lib/plans";
import { HttpError } from "@/lib/errors";
export async function createProject(userId: string, data: CreateProjectInput) {
const user = await userRepo.findById(userId);
if (!user) throw new HttpError(404, "User not found");
const limits = getLimits(user.plan);
const existing = await projectRepo.countByUser(userId);
if (existing >= limits.projects) {
throw new HttpError(
402,
`Your ${user.plan} plan allows ${limits.projects} projects. Upgrade to create more.`
);
}
return projectRepo.create({ ...data, userId });
}
402 Payment Required is the right status code for a plan limit. It tells the client to show an upgrade prompt rather than a generic error message.
Your Stripe webhook handler already receives subscription events. Add plan sync alongside the existing logic:
// inside your POST /api/webhooks/stripe route handler
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
const plan =
sub.status === "active"
? resolvePlan(sub.items.data[0].price.id)
: "free";
await userRepo.updateByStripeCustomerId(customerId, { plan });
break;
}
When a user upgrades, downgrades, or cancels, the plan column updates automatically. No polling. No scheduled job.
In a server component, read the current usage alongside the limit and pass both down:
// app/(main)/projects/page.tsx
const user = await userService.getById(session.userId);
const limits = getLimits(user.plan);
const count = await projectService.countByUser(session.userId);
const atLimit = count >= limits.projects;
return <ProjectsList atLimit={atLimit} plan={user.plan} projectLimit={limits.projects} />;
In the component, disable the create button and explain why:
{atLimit && (
<p className="text-sm text-muted-foreground">
You have reached the {projectLimit}-project limit on the {plan} plan.{" "}
<a href="/billing" className="underline">Upgrade to add more.</a>
</p>
)}
<Button disabled={atLimit}>New project</Button>
The UI hint and the service guard both matter. The UI prevents users from hitting a wall with no explanation. The service guard ensures the limit holds even if the frontend check is bypassed.
The Stripe subscriptions post covers checkout and the customer portal. This is what makes the plan mean something once a user subscribes.
If you are starting from the boilerplate, auth, Stripe checkout, and the module structure are already wired. Clone the repo and have plan gating running in under 30 minutes -- no infrastructure setup needed. Try it at boilerplate.iteam-company.com.