Add Stripe Payments to a Next.js Template (2026)
nextjsstripepaymentssubscriptionsbillingintegrationtutorialwebhooks10 min read

Add Stripe Payments to a Next.js Template (2026)

Gaurav Guha

Add Stripe Payments to a Next.js Template in 2026

Stripe is the default payment processor for Next.js SaaS in 2026. The API is excellent, the SDKs are battle-tested, the dashboard is fast, and the developer documentation is the gold standard. Most setup gotchas come from misunderstanding webhooks, not from the API itself.

This guide walks the full wiring: install the SDK, create products in Stripe, build the checkout flow, handle webhooks, manage subscriptions, and the patterns that actually survive contact with real billing.


Skip the billing build entirely: get every kit for $499

The SaaS Starter Kit ships billing UI (plan picker, checkout flow, invoices, payment method, upgrade/downgrade) pre-built. Add your Stripe keys and most of the integration is done. All Access unlocks every kit on thefrontkit for $499 one-time.


Why Stripe Is the Default in 2026

The alternatives:

  • Paddle — handles tax for you (Merchant of Record), great for global SaaS. Higher fees, less flexibility.
  • Lemon Squeezy — easier than Stripe for digital products, MoR model. Smaller ecosystem.
  • Razorpay — the default in India.
  • Adyen — enterprise scale, complex integration.

For most US-headquartered SaaS, Stripe wins on developer experience, fee structure (2.9% + 30¢), and ecosystem integrations. The trade-off vs Paddle/LemonSqueezy: you're responsible for sales tax (Stripe Tax helps, but it's still your problem).

Step 1: Create the Stripe Account and Products

Sign up at stripe.com. In test mode (the default), create your products:

  1. Products > Add product
  2. Name: "Pro Plan"
  3. Pricing: Recurring, $29/month (you'll also want an annual price at $290/year for the discount)
  4. Save and grab the price ID (price_xxx)

Repeat for each tier. You'll usually have 2-3 tiers (Starter, Pro, Enterprise) each with monthly and annual pricing.

Grab two values from the dashboard:

  • STRIPE_PUBLISHABLE_KEY (pk_test_... in test mode, pk_live_... in production)
  • STRIPE_SECRET_KEY (sk_test_... / sk_live_...)

Step 2: Install the SDK

npm install stripe @stripe/stripe-js

Add to .env.local:

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_SITE_URL=http://localhost:3000

Create a Stripe client wrapper at lib/stripe.ts:

import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-10-29.acacia',
  typescript: true,
});

Step 3: Build the Checkout Session

Create a server action to start checkout:

'use server';
import { stripe } from '@/lib/stripe';
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';

export async function createCheckoutSession(priceId: string) {
  const { userId } = await auth();
  if (!userId) throw new Error('Unauthorized');

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`,
    client_reference_id: userId,
    metadata: { userId },
    subscription_data: { metadata: { userId } },
    allow_promotion_codes: true,
    automatic_tax: { enabled: true },
  });

  if (session.url) redirect(session.url);
}

The key things:

  • client_reference_id: userId so you can link the session to your user when the webhook fires
  • metadata: { userId } on the session and subscription for redundant traceability
  • allow_promotion_codes: true so users can enter discount codes
  • automatic_tax: { enabled: true } to enable Stripe Tax (requires setup; see Step 6)

Step 4: Trigger Checkout from the UI

The pricing page button:

'use client';
import { createCheckoutSession } from '@/app/actions/stripe';

export function CheckoutButton({ priceId, label }: { priceId: string; label: string }) {
  return (
    <form action={async () => { await createCheckoutSession(priceId); }}>
      <button type="submit">{label}</button>
    </form>
  );
}

When the user clicks, the server action creates a session and redirects to Stripe-hosted checkout. They complete payment on Stripe's domain, then return to /billing/success.

Step 5: Wire the Webhook

This is the step most teams underbuild. Stripe Checkout updates Stripe's database, not yours. Without webhooks, your application doesn't know the user paid.

Create app/api/webhooks/stripe/route.ts:

import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import Stripe from 'stripe';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = (await headers()).get('stripe-signature');

  if (!signature) {
    return new Response('Missing signature', { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response(`Webhook signature failed: ${err}`, { status: 400 });
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object;
      const userId = session.client_reference_id || session.metadata?.userId;
      if (userId && session.subscription) {
        await db.subscription.upsert({
          where: { userId },
          create: {
            userId,
            stripeCustomerId: session.customer as string,
            stripeSubscriptionId: session.subscription as string,
            status: 'active',
          },
          update: {
            stripeCustomerId: session.customer as string,
            stripeSubscriptionId: session.subscription as string,
            status: 'active',
          },
        });
      }
      break;
    }

    case 'customer.subscription.updated': {
      const sub = event.data.object;
      await db.subscription.update({
        where: { stripeSubscriptionId: sub.id },
        data: {
          status: sub.status,
          currentPeriodEnd: new Date(sub.current_period_end * 1000),
          cancelAtPeriodEnd: sub.cancel_at_period_end,
        },
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const sub = event.data.object;
      await db.subscription.update({
        where: { stripeSubscriptionId: sub.id },
        data: { status: 'canceled' },
      });
      break;
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object;
      // Notify the user, mark subscription as past_due, etc.
      break;
    }
  }

  return new Response('OK');
}

In the Stripe dashboard, add a webhook endpoint pointing to https://your-app.com/api/webhooks/stripe and subscribe to:

  • checkout.session.completed
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_failed
  • invoice.paid

Grab the webhook signing secret (whsec_...) and put it in STRIPE_WEBHOOK_SECRET.

For local development, use the Stripe CLI to forward events:

stripe listen --forward-to http://localhost:3000/api/webhooks/stripe

This prints a local whsec_... secret to use during dev.

Step 6: Stripe Customer Portal

Don't build your own subscription management UI. Stripe has one.

'use server';
import { stripe } from '@/lib/stripe';
import { auth } from '@clerk/nextjs/server';
import { db } from '@/lib/db';
import { redirect } from 'next/navigation';

export async function openCustomerPortal() {
  const { userId } = await auth();
  if (!userId) throw new Error('Unauthorized');

  const subscription = await db.subscription.findUnique({ where: { userId } });
  if (!subscription) throw new Error('No subscription');

  const session = await stripe.billingPortal.sessions.create({
    customer: subscription.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`,
  });

  redirect(session.url);
}

The portal handles plan upgrades/downgrades, payment method updates, invoice downloads, and cancellation. Enable the features you want in the Stripe dashboard under Settings > Customer portal.

Step 7: Enable Stripe Tax

If you're selling globally (or even just in the US, given state sales tax rules), Stripe Tax is the simplest path.

In the dashboard:

  1. Settings > Tax > Enable Stripe Tax
  2. Register in the jurisdictions where you have nexus (US states, EU countries, etc.)
  3. Set product tax codes for your prices

With automatic_tax: { enabled: true } in the checkout session (Step 3), Stripe calculates and collects tax automatically.

For pre-revenue products, US-only, with no nexus outside your home state, you can skip this initially. Add Stripe Tax before you cross a state's economic nexus threshold (usually $100k or 200 transactions per year).

Step 8: One-Time Payments (Not Subscriptions)

For products like the $499 All Access pass, use mode payment instead of subscription:

const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout/success`,
  cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout`,
  client_reference_id: userId,
  metadata: { userId, type: 'one_time' },
});

The webhook handler still fires checkout.session.completed, but you grant lifetime access in your database instead of creating a subscription record.

Common Gotchas

Skipping webhooks entirely. The most common mistake. Users pay, Stripe is happy, your app has no record. Always implement the webhook before going live.

Webhook signature verification failures. The webhook secret in STRIPE_WEBHOOK_SECRET must match the endpoint's secret in the Stripe dashboard. Test mode and live mode have different secrets. Mixing them up means every webhook returns 400.

Reading webhook body twice. Once you call req.text() to verify the signature, the body is consumed. If you JSON.parse it before verification, the verification fails. Always verify first.

Hardcoded price IDs. Test mode prices differ from live mode. Use environment variables for price IDs so the test/live switch is automatic.

Forgetting test mode webhooks. During development, use stripe listen to forward events. Don't try to test against the real webhook endpoint.

Building your own subscription management UI. The Stripe Customer Portal is free, polished, and handles every edge case. Use it.

No subscription_data metadata. Without subscription_data: { metadata: { userId } }, you can't link incoming subscription update events back to your user. Always set it.

Ignoring invoice.payment_failed. When a card expires or fails, Stripe sends this event. Without handling it, you'll silently lose customers whose payments fail. Send a recovery email and mark the subscription as past-due.

Treating Stripe Customer ID as optional. It's the link between Stripe and your database. Store it on the user record (or subscription record). Without it, you can't look up subscriptions, open the customer portal, or refund anything.

Adjacent Reads

FAQ

Should I use Stripe Checkout or Stripe Elements? Stripe Checkout is the hosted page (Step 3 pattern above). Stripe Elements is the embedded card form. Checkout is faster to ship, handles all the edge cases (Apple Pay, Google Pay, tax, address collection) automatically, and the URL changes mid-flow (which most users don't mind). Elements gives you full UI control at the cost of much more work. Default to Checkout. Use Elements only when the embedded experience is critical (e.g., a multi-step onboarding where leaving the page would be jarring).

Do I need subscriptions or one-time payments? Depends on the product. SaaS = subscriptions. Course, ebook, lifetime access pass = one-time. Some products do both (a SaaS with a one-time setup fee + monthly subscription). Stripe supports all of these. Pick based on your business model, not what's easier to build.

How do I handle trials? Two patterns. First: a free trial of N days before billing kicks in, set on the price with trial_period_days. Second: a paywall after N days of usage, gated in your application. The Stripe-native trial is simpler for time-based trials. App-gated is better for usage-based trials (e.g., "try the first 100 API calls free").

Can I use Stripe in India / for INR pricing? Stripe supports INR for businesses registered outside India. For India-headquartered businesses, the local options (Razorpay, Cashfree, Instamojo) are typically better because they handle Indian tax requirements and accept domestic methods like UPI. Razorpay is the closest equivalent to Stripe.

How do I show invoices in my app? Two patterns. First (recommended): link out to the Stripe Customer Portal, which shows all invoices with download buttons. Second: fetch invoices via the API (stripe.invoices.list({ customer: customerId })) and render your own UI. The portal is free; the custom UI is several days of work. Use the portal unless you have a specific reason not to.

What happens when a customer cancels mid-month? Default: their subscription stays active until the period end. cancel_at_period_end: true in the cancellation call means access continues, then expires. They are not charged again. To revoke access immediately and prorate a refund, use cancellation_details with the right parameters. Be explicit about which policy you want in your terms of service.

Gaurav Guha, Founder of TheFrontKit

Gaurav Guha

Founder, TheFrontKit

Building production-ready frontend kits for SaaS and AI products. Previously co-created NativeBase (100K+ weekly npm downloads). Also runs Spartan Labs, a RevOps automation agency for B2B SaaS. Writes about accessible UI architecture, design tokens, and shipping faster with Next.js.

Learn more

Related Posts

Next.js SaaS Template

Dashboard, auth screens, settings, and 50+ accessible components.