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

Usage limits in Next.js SaaS -- gating features by Stripe plan with Drizzle ORM

June 19, 2026
nextjsstripedrizzle-ormsaasauth

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.

What you are building

A plan-gating pattern with three parts:

  1. A plan column on your users table that Stripe webhooks keep in sync
  2. A plan config that maps each tier to its limits
  3. A service-layer guard that enforces those limits before creating resources

No middleware. No third-party library. The check lives where business logic belongs -- in the service layer.

Store the plan on the user

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.

Define your plan limits

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.

Enforce the limit in the service layer

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.

Keep the plan in sync via Stripe webhook

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.

Show the limit in the UI

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.

What you end up with

  • Plan enforcement that lives in the service layer, not scattered through middleware or components
  • One config file to raise or lower limits without touching business logic
  • Stripe webhooks that keep the plan column current automatically
  • 402 responses that client code can catch and route to the billing page
  • A UI that explains the limit before the user hits it

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.