DEV Community

Gaurav Guha
Gaurav Guha

Posted on Originally published at thefrontkit.com

How to add Stripe payments to a Next.js app with checkout, webhooks, and portal

You need to let users pay with Stripe in your Next.js app, but you're not sure how to create the checkout flow, keep your DB in sync, and give customers a self-service portal.

Step 1: Create the Stripe account and products

  1. Sign up at https://stripe.com and stay in test mode.
  2. Products > Add product
  3. Name: "Pro Plan"
  4. Pricing: Recurring, $29/month (add an annual price at $290/year for the discount)
  5. Save and copy the price ID (price_xxx)

Repeat for each tier (Starter, Pro, Enterprise). Grab two keys from the dashboard:

  • STRIPE_PUBLISHABLE_KEY (pk_test_... in test mode)
  • STRIPE_SECRET_KEY (sk_test_...)

You'll also need a webhook signing secret later.

Step 2: Install the SDK

npm install stripe @stripe/stripe-js
Enter fullscreen mode Exit fullscreen mode

Add the keys 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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • client_reference_id links the session to your user.
  • metadata on the session and subscription gives redundant traceability.
  • allow_promotion_codes enables discount codes.
  • automatic_tax turns on Stripe Tax (requires setup).

Step 4: Trigger Checkout from the UI

'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>
  );
}
Enter fullscreen mode Exit fullscreen mode

When the button is clicked, the server action creates a session and redirects the user to Stripe-hosted checkout. After payment they return to /billing/success.

Step 5: Wire the Webhook

Stripe updates its own records; without a webhook your DB never knows a payment succeeded.

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');
}
Enter fullscreen mode Exit fullscreen mode

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

Copy the webhook signing secret (whsec_...) into STRIPE_WEBHOOK_SECRET.

For local development, forward events with the Stripe CLI:

stripe listen --forward-to http://localhost:3000/api/webhooks/stripe
Enter fullscreen mode Exit fullscreen mode

The CLI prints a local whsec_... secret you can use during dev.

Step 6: Stripe Customer Portal

Instead of building your own subscription management UI, redirect users to Stripe's portal.

'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);
}
Enter fullscreen mode Exit fullscreen mode

The portal lets users upgrade/downgrade plans, update payment methods, download invoices, and cancel. Enable the desired features in Stripe under Settings > Customer portal.

Step 7: Enable Stripe Tax (optional)

If you need to collect sales tax, enable Stripe Tax in the dashboard (Settings > Tax). Register the jurisdictions where you have nexus and assign tax codes to your prices. With automatic_tax: { enabled: true } in the checkout session, Stripe will calculate and collect tax automatically.

You can skip this step for a US-only, pre-revenue product and add it later when you cross a state's economic nexus threshold.

Step 8: One-Time Payments (non-subscription)

For a single purchase, set 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' },
});
Enter fullscreen mode Exit fullscreen mode

The same webhook (checkout.session.completed) fires; grant lifetime access in your DB rather than creating a subscription record.

Common Gotchas

  • Skipping webhooks entirely. Users can pay, Stripe shows a successful charge, but your app never records it. Implement the webhook before you go live.
  • Mismatched environment keys. Using a live publishable key with a test secret (or vice-versa) causes "Invalid API Key" errors. Keep test and production keys separate.
  • Forgot to set client_reference_id. Without it you can't reliably map a checkout session back to your user in the webhook.
  • Webhook URL not reachable. In production the endpoint must be publicly accessible and use HTTPS; otherwise Stripe will keep retrying and eventually disable the webhook.
  • Tax settings not enabled but automatic_tax set. Stripe will reject the session creation; either enable Stripe Tax or remove the flag.

Address these issues early and the Stripe integration will stay in sync with your Next.js app.


This is a shorter version of a guide I wrote on thefrontkit. The full post has the complete walkthrough.

Top comments (0)