You have a SaaS. You want your first 200 customers fast -- ideally before you have to run ads or wait for SEO to kick in. A lifetime deal is one of the fastest ways to get there. AppSumo, Product Hunt, and your own mailing list will convert people who will never subscribe monthly but will happily pay once for permanent access.
The problem is that most SaaS stacks are built around subscriptions. Adding a lifetime option means your access-check logic needs to handle two paths: "is this user subscribed?" and "did this user buy lifetime access?" Get that wrong and you either lock out paying customers or hand out free access to anyone who asks.
Here is the exact pattern to add a lifetime deal to a Next.js SaaS that already has Stripe subscriptions -- about an hour of work.
Subscription access and lifetime access are different things in your database. The cleanest approach is a single boolean on the user record: lifetimeAccess. Your gating logic then becomes:
has access = active subscription OR lifetimeAccess === true
No third table, no state machine, no scheduled cleanup job. When someone buys the LTD, you flip the flag. It never expires.
Add one column to your user table in modules/user/user.schema.ts:
lifetimeAccess: boolean("lifetime_access").notNull().default(false),
Run npm run db:generate and npm run db:migrate. Existing users default to false -- nothing breaks.
Create a new product in Stripe with a one-time price, not a recurring price. Copy the price ID and add it to your environment:
NEXT_PUBLIC_STRIPE_PRICE_LTD=price_REPLACE_ME
Keep it separate from your subscription price IDs. Stripe treats one-time and recurring products differently at the checkout level.
Your existing subscription route creates a Stripe Session in subscription mode. For the LTD, add a new route in payment mode:
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: process.env.NEXT_PUBLIC_STRIPE_PRICE_LTD, quantity: 1 }],
customer_email: user.email,
metadata: { userId: user.id, type: "ltd" },
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?ltd=success`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
});
The metadata.type field is how your webhook knows this is a lifetime purchase, not a subscription.
In your Stripe webhook handler, add a branch for checkout.session.completed where type === "ltd":
if (event.type === "checkout.session.completed") {
const session = event.data.object;
if (session.metadata?.type === "ltd") {
await userRepo.setLifetimeAccess(session.metadata.userId, true);
}
}
setLifetimeAccess is one update query in your user repository. The webhook fires within seconds of payment. The user reloads the page and has full access.
For more on handling Stripe webhooks reliably in this stack, see Stripe subscriptions in Next.js.
Wherever you currently check for an active subscription, extend the condition:
const hasAccess = user.lifetimeAccess || isSubscriptionActive(user.subscriptionStatus);
If you have a shared getUserAccess helper or middleware, update it in one place. Do not copy-paste this check across every protected route.
Add an LTD tier next to your monthly and annual options. Be specific about what it includes. Common patterns that work:
A genuine deadline converts better than a permanent "buy once" button. Scarcity you can actually close is not a trick -- it is an honest offer with a window.
LTDs work best in two situations. You are launching and need your first wave of customers who will talk about your product publicly. Or you are listing on a platform like AppSumo that has a built-in audience of deal hunters who rarely subscribe monthly but often become vocal advocates.
Do not keep the LTD open forever. It creates pricing confusion and undercuts your recurring revenue. Run it for two to eight weeks, close it, and move on. The urgency is the point.
This Next.js boilerplate already has Stripe subscriptions, JWT auth, Drizzle ORM, and webhook infrastructure wired up. Adding a lifetime deal is a schema column, a new checkout route, and one webhook branch -- not a rebuild. You can ship it before your launch email goes out.