You spent a week wiring up Stripe plans. You have a free tier, a Pro tier, and a feature gate that returns 403 when a free user tries to access something they have not paid for. Now you are watching your dashboard and nobody is upgrading.
The problem is usually not the gate itself. It is everything around it -- the user has no idea how close they are to hitting a limit, and when they finally do hit one, they see an error message instead of a reason to upgrade.
This post gives you a concrete framework for the free-tier UX that converts: usage counters, inline upgrade prompts, and gate walls that explain value instead of blocking silently.
Upgrade prompts convert best in three specific situations:
Most founders only build the gate wall. The soft warning and the value moment are where the real conversion happens.
Every plan limit needs a counter you can query cheaply. For a project limit, that is a simple count query:
const projectCount = await db
.select({ count: count() })
.from(projectTable)
.where(eq(projectTable.userId, userId))
Wrap this in your service layer so the route stays thin. Return both the count and the plan limit so the client can show a progress bar without a second request.
The Next.js SaaS boilerplate already has the service layer pattern and Drizzle ORM wired up -- you add the counter query and a getPlanLimits(user) helper that reads limits from your plan config, not from the database.
The gate wall should never be a bare error. Build an UpgradePrompt component that receives the feature name and shows three things:
For the soft warning, a banner above the relevant list is enough: "You have used 4 of 5 projects. Upgrade to Pro for unlimited projects."
Both share the same Stripe checkout URL -- the difference is context and timing.
The rule is: show usage counters wherever the user manages the resource being limited.
If you limit projects, show the counter on the projects list page. If you limit team members, show it on the team settings page. If you limit AI requests per month, show it in the header or sidebar so it is always visible.
Do not put usage counters only on a billing page that users visit once. Put them where the behavior happens.
This one is often skipped entirely. After a user completes a meaningful action on the free plan, a short inline prompt can be the most natural upgrade ask you make.
The pattern: after the success toast, show a secondary prompt. "You just exported your first report. Pro users can schedule exports automatically -- see Pro features." One sentence. One link. Not a modal.
This works because the user just got value. They are not hitting a wall -- they are imagining what more would look like.
When the user clicks "Upgrade," send them to a Stripe Checkout session with client_reference_id set to their user ID. Your webhook handler already updates the user's plan in Drizzle ORM. After the webhook fires, the getPlanLimits() call returns the higher limits and the counters update automatically.
No state to sync manually. The plan change propagates on the next page load.
For the technical setup behind plan gating, see Usage limits in Next.js SaaS -- gating features by Stripe plan. For the strategic decision of how to structure your free tier, see Freemium vs Free Trial -- Which Pricing Model Fits Your SaaS.
If any of these is a no, that is your first conversion fix.
If you are starting from scratch, the boilerplate ships with Stripe subscriptions, JWT auth, Drizzle ORM, and the service layer pattern already in place -- so you add the usage counters and upgrade prompts, not the billing plumbing. Get the boilerplate and ship your free tier this week.