Skip to main content

Overview

The Next.js SaaS Starter integrates with Stripe for complete subscription billing management. Each team can have a Stripe subscription, and the system handles checkout, webhooks, and customer portal access.

Setup

Stripe Client

The Stripe client is initialized in lib/payments/stripe.ts:
lib/payments/stripe.ts
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-04-30.basil'
});
Ensure you have set the STRIPE_SECRET_KEY environment variable with your Stripe secret key.

Checkout Flow

Creating a Checkout Session

Use createCheckoutSession() to initiate a Stripe checkout for a team:
lib/payments/stripe.ts
export async function createCheckoutSession({
  team,
  priceId
}: {
  team: Team | null;
  priceId: string;
}) {
  const user = await getUser();

  if (!team || !user) {
    redirect(`/sign-up?redirect=checkout&priceId=${priceId}`);
  }

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: [
      {
        price: priceId,
        quantity: 1
      }
    ],
    mode: 'subscription',
    success_url: `${process.env.BASE_URL}/api/stripe/checkout?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.BASE_URL}/pricing`,
    customer: team.stripeCustomerId || undefined,
    client_reference_id: user.id.toString(),
    allow_promotion_codes: true,
    subscription_data: {
      trial_period_days: 14
    }
  });

  redirect(session.url!);
}
team
Team | null
required
The team object. If null, the user is redirected to sign up.
priceId
string
required
The Stripe price ID for the subscription plan.

Checkout Features

1

Authentication check

Verifies the user is authenticated and has a team. Redirects to sign-up if not.
2

Session creation

Creates a Stripe checkout session with the specified price.
3

Customer linking

Links to existing Stripe customer if team already has one.
4

Trial period

Automatically applies a 14-day trial period to new subscriptions.
5

Promotion codes

Enables promotion code input during checkout.
The client_reference_id stores the user ID for linking the subscription to the correct team after checkout.

Customer Portal

Creating a Portal Session

The customer portal allows users to manage their subscription, update payment methods, and cancel:
lib/payments/stripe.ts
export async function createCustomerPortalSession(team: Team) {
  if (!team.stripeCustomerId || !team.stripeProductId) {
    redirect('/pricing');
  }

  let configuration: Stripe.BillingPortal.Configuration;
  const configurations = await stripe.billingPortal.configurations.list();

  if (configurations.data.length > 0) {
    configuration = configurations.data[0];
  } else {
    const product = await stripe.products.retrieve(team.stripeProductId);
    if (!product.active) {
      throw new Error("Team's product is not active in Stripe");
    }

    const prices = await stripe.prices.list({
      product: product.id,
      active: true
    });

    configuration = await stripe.billingPortal.configurations.create({
      business_profile: {
        headline: 'Manage your subscription'
      },
      features: {
        subscription_update: {
          enabled: true,
          default_allowed_updates: ['price', 'quantity', 'promotion_code'],
          proration_behavior: 'create_prorations',
          products: [
            {
              product: product.id,
              prices: prices.data.map((price) => price.id)
            }
          ]
        },
        subscription_cancel: {
          enabled: true,
          mode: 'at_period_end',
          cancellation_reason: {
            enabled: true,
            options: [
              'too_expensive',
              'missing_features',
              'switched_service',
              'unused',
              'other'
            ]
          }
        },
        payment_method_update: {
          enabled: true
        }
      }
    });
  }

  return stripe.billingPortal.sessions.create({
    customer: team.stripeCustomerId,
    return_url: `${process.env.BASE_URL}/dashboard`,
    configuration: configuration.id
  });
}

Portal Features

The customer portal is configured to allow:
  • Subscription Updates: Change plan, quantity, or apply promotion codes
  • Cancellation: Cancel subscription at the end of the billing period
  • Payment Methods: Update credit card information
  • Cancellation Feedback: Collect reasons for cancellation
The portal configuration is created automatically if none exists. This ensures teams can always access subscription management.

Webhook Handling

Subscription Changes

Handle subscription lifecycle events with handleSubscriptionChange():
lib/payments/stripe.ts
export async function handleSubscriptionChange(
  subscription: Stripe.Subscription
) {
  const customerId = subscription.customer as string;
  const subscriptionId = subscription.id;
  const status = subscription.status;

  const team = await getTeamByStripeCustomerId(customerId);

  if (!team) {
    console.error('Team not found for Stripe customer:', customerId);
    return;
  }

  if (status === 'active' || status === 'trialing') {
    const plan = subscription.items.data[0]?.plan;
    await updateTeamSubscription(team.id, {
      stripeSubscriptionId: subscriptionId,
      stripeProductId: plan?.product as string,
      planName: (plan?.product as Stripe.Product).name,
      subscriptionStatus: status
    });
  } else if (status === 'canceled' || status === 'unpaid') {
    await updateTeamSubscription(team.id, {
      stripeSubscriptionId: null,
      stripeProductId: null,
      planName: null,
      subscriptionStatus: status
    });
  }
}

Handled Subscription Statuses

// Subscription is active and paid
status: 'active'

Database Integration

Team Subscription Fields

The teams table stores Stripe-related data:
lib/db/schema.ts
export const teams = pgTable('teams', {
  // ... other fields
  stripeCustomerId: text('stripe_customer_id').unique(),
  stripeSubscriptionId: text('stripe_subscription_id').unique(),
  stripeProductId: text('stripe_product_id'),
  planName: varchar('plan_name', { length: 50 }),
  subscriptionStatus: varchar('subscription_status', { length: 20 }),
});

Updating Team Subscriptions

lib/db/queries.ts
export async function updateTeamSubscription(
  teamId: number,
  subscriptionData: {
    stripeSubscriptionId: string | null;
    stripeProductId: string | null;
    planName: string | null;
    subscriptionStatus: string;
  }
) {
  await db
    .update(teams)
    .set({
      ...subscriptionData,
      updatedAt: new Date()
    })
    .where(eq(teams.id, teamId));
}

Product and Price Management

Fetching Products

Retrieve all active Stripe products:
lib/payments/stripe.ts
export async function getStripeProducts() {
  const products = await stripe.products.list({
    active: true,
    expand: ['data.default_price']
  });

  return products.data.map((product) => ({
    id: product.id,
    name: product.name,
    description: product.description,
    defaultPriceId:
      typeof product.default_price === 'string'
        ? product.default_price
        : product.default_price?.id
  }));
}

Fetching Prices

Retrieve all active recurring prices:
lib/payments/stripe.ts
export async function getStripePrices() {
  const prices = await stripe.prices.list({
    expand: ['data.product'],
    active: true,
    type: 'recurring'
  });

  return prices.data.map((price) => ({
    id: price.id,
    productId:
      typeof price.product === 'string' ? price.product : price.product.id,
    unitAmount: price.unit_amount,
    currency: price.currency,
    interval: price.recurring?.interval,
    trialPeriodDays: price.recurring?.trial_period_days
  }));
}

Environment Variables

STRIPE_SECRET_KEY
string
required
Your Stripe secret key (starts with sk_)
BASE_URL
string
required
The base URL of your application for redirect URLs

Best Practices

Always validate webhook signatures in production to ensure events are genuinely from Stripe.
Use Stripe’s test mode during development. Test mode keys start with sk_test_ and use test card numbers.
The trial period is set to 14 days by default. Modify trial_period_days in createCheckoutSession() to change this.

Build docs developers (and LLMs) love