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

Stripe coupon and promo codes in Next.js -- apply discounts at checkout and track redemptions with Drizzle ORM

July 3, 2026
stripenextjsdrizzle-ormsaaspayments

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.

What you are building

  • A promo code input on your pricing page
  • A validation endpoint that checks the code against Stripe before checkout
  • A Stripe Checkout session that applies the discount automatically
  • A redemptions table in Drizzle ORM that logs every successful code use

Step 1: Create a coupon and promo code in Stripe

You 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.

Step 2: Validate the code before checkout

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.

Step 3: Apply the code at Stripe 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.

Step 4: Track redemptions in Drizzle ORM

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.

Step 5: Show the discount on your pricing page

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.

What you get

For under 150 lines of new code across three files:

  • Validated codes before the user hits Stripe (no silent failures mid-checkout)
  • Stripe enforces expiry and max redemptions automatically
  • A redemptions table you can query to compare campaign performance
  • Works with both subscription and one-time payment checkout modes

Stripe 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.