Every SaaS eventually needs discount codes. Launch promos, affiliate deals, customer retention -- promo codes are the fastest lever founders reach for. Stripe handles the hard part (discount math, expiry, usage limits). Your job is to wire the input field to checkout and track what actually got used.
Here is how to add this to your Next.js SaaS in an afternoon.
redemptions table in Drizzle ORM that logs every successful code useYou can do this in the Stripe Dashboard, but doing it in code lets you automate it for campaigns:
// run once, e.g. via a one-off script or admin route
const coupon = await stripe.coupons.create({
percent_off: 20,
duration: 'once',
name: 'LAUNCH20',
});
const promoCode = await stripe.promotionCodes.create({
coupon: coupon.id,
code: 'LAUNCH20',
max_redemptions: 100,
expires_at: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7 days
});
Stripe stores the coupon (the discount rule) separately from the promo code (the human-readable string). One coupon can have many promo codes pointing to it, which is useful when you want different codes per affiliate.
Do not let users discover a broken code mid-checkout. Add a lightweight validation endpoint:
// app/api/promo/validate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { code } = await req.json();
const result = await stripe.promotionCodes.list({ code, active: true, limit: 1 });
if (!result.data.length) {
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 400 });
}
const promo = result.data[0];
const coupon = promo.coupon;
const discount = coupon.percent_off
? `${coupon.percent_off}% off`
: `$${((coupon.amount_off ?? 0) / 100).toFixed(2)} off`;
return NextResponse.json({ valid: true, discount, promoCodeId: promo.id });
}
This returns a user-friendly label ("20% off") and the promoCodeId you will pass to Checkout.
When you create the Checkout Session, pass discounts with the validated promo code ID:
// modules/billing/billing.service.ts
export async function createCheckoutSession(
userId: string,
priceId: string,
promoCodeId?: string,
) {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
discounts: promoCodeId ? [{ promotion_code: promoCodeId }] : [],
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
metadata: { userId },
});
return session.url;
}
If you want users to enter codes inside Stripe's hosted checkout UI instead, swap discounts for allow_promotion_codes: true and skip the validation endpoint entirely.
Your Stripe webhook fires after a successful checkout. Add a redemptions table and log every use:
// modules/redemption/redemption.schema.ts
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
export const redemptionTable = pgTable('redemptions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
promoCode: text('promo_code').notNull(),
couponId: text('coupon_id').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
Register it in db/drizzle.ts, generate and apply the migration, then write to it inside your checkout.session.completed handler:
// app/api/webhooks/stripe/route.ts (inside checkout.session.completed)
const session = event.data.object as Stripe.Checkout.Session;
if (session.discounts?.length) {
const promo = session.discounts[0].promotion_code;
if (promo && typeof promo !== 'string') {
await db.insert(redemptionTable).values({
userId: session.metadata?.userId ?? '',
promoCode: promo.code,
couponId: typeof promo.coupon === 'string' ? promo.coupon : promo.coupon.id,
});
}
}
Now every converted discount is logged with the user, the code, and the timestamp.
Wire a simple input to the validation endpoint:
// components/PromoCodeInput.tsx
'use client';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
interface Props {
onApply: (promoCodeId: string, label: string) => void;
}
export function PromoCodeInput({ onApply }: Props) {
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleApply() {
setLoading(true);
setError('');
const res = await fetch('/api/promo/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error);
} else {
onApply(data.promoCodeId, data.discount);
}
setLoading(false);
}
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Input
placeholder="Promo code"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<Button onClick={handleApply} disabled={loading}>
Apply
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
When the user clicks "Subscribe," pass the returned promoCodeId to your checkout API route.
For under 150 lines of new code across three files:
redemptions table you can query to compare campaign performanceStripe does the discount math. Your code just connects the dots.
Want subscriptions, webhooks, and billing already wired before you write a single line? Clone this boilerplate and add your promo code logic on top -- the Stripe stack is already there.