PropelKitPropelKit

How to Set Up Stripe Payments in Next.js (Complete Guide)

Tanishq AgarwalFebruary 12, 202610 min read
Share:

title: "How to Set Up Stripe Payments in Next.js (Complete Guide)" slug: "stripe-payments-nextjs-guide" excerpt: "Learn how to integrate Stripe payments into your Next.js app with checkout sessions, webhooks, and subscription management. Full code examples." tags: ["nextjs", "stripe", "payments", "tutorial"] metaTitle: "How to Set Up Stripe Payments in Next.js (Complete 2026 Guide)" metaDescription: "Learn how to integrate Stripe payments into your Next.js app with checkout sessions, webhooks, and subscription management. Full code examples." authorName: "Tanishq Agarwal" publishedAt: "2026-02-12"

Integrating Stripe payments into a Next.js application is one of the most common tasks for any SaaS developer. Whether you're building a one-time purchase flow, a subscription service, or a usage-based billing system, Stripe has you covered.

In this guide, we'll walk through the complete process of setting up Stripe in a Next.js 15 application using the App Router. We'll cover everything from initial setup to handling webhooks in production.

Want to skip the setup? PropelKit includes Stripe integration out of the box, along with Razorpay and DodoPayments support.

Prerequisites

Before we begin, make sure you have:

  • A Next.js 15 project with the App Router
  • A Stripe account (sign up here)
  • Node.js 18 or later
  • Basic TypeScript knowledge

Step 1: Install Dependencies

First, install the Stripe packages:

npm install stripe @stripe/stripe-js
  • stripe — The server-side Stripe SDK for Node.js
  • @stripe/stripe-js — The client-side Stripe.js loader

Step 2: Set Up Environment Variables

Add your Stripe keys to .env.local:

STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Important security notes:

  • Never expose your secret key to the client. Only the publishable key should have the NEXT_PUBLIC_ prefix.
  • Use test keys during development (they start with sk_test_ and pk_test_).
  • The webhook secret is used to verify that webhook events actually come from Stripe.

Step 3: Create a Stripe Client Singleton

Create a server-side Stripe client that you can import anywhere:

// src/lib/stripe.ts
import Stripe from 'stripe';

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error('STRIPE_SECRET_KEY is not set');
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2024-12-18.acacia',
  typescript: true,
});

Always specify the API version explicitly. This protects you from breaking changes when Stripe updates their API.

Step 4: Create a Checkout Session API Route

The most common Stripe integration pattern is Checkout Sessions. Stripe hosts the entire payment page for you, handling card validation, 3D Secure, and compliance.

// src/app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { z } from 'zod';

const CheckoutSchema = z.object({
  priceId: z.string().startsWith('price_'),
  successUrl: z.string().url(),
  cancelUrl: z.string().url(),
  customerEmail: z.string().email().optional(),
});

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { priceId, successUrl, cancelUrl, customerEmail } =
      CheckoutSchema.parse(body);

    const session = await stripe.checkout.sessions.create({
      mode: 'subscription', // or 'payment' for one-time
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      success_url: `${successUrl}?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: cancelUrl,
      customer_email: customerEmail,
      metadata: {
        // Add any metadata you need for webhook processing
        source: 'web_app',
      },
    });

    return NextResponse.json({ url: session.url });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'Invalid request data' },
        { status: 400 }
      );
    }

    console.error('Stripe checkout error:', error);
    return NextResponse.json(
      { error: 'Failed to create checkout session' },
      { status: 500 }
    );
  }
}

Key points:

  • Always validate input with Zod or a similar library. Never trust client data.
  • Use metadata to pass information you'll need when processing the webhook.
  • Handle errors gracefully and never expose internal error details to the client.

Step 5: Create the Client-Side Checkout Button

// src/components/CheckoutButton.tsx
'use client';

import { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';

const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);

interface CheckoutButtonProps {
  priceId: string;
  label?: string;
}

export function CheckoutButton({
  priceId,
  label = 'Subscribe',
}: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);

    try {
      const response = await fetch('/api/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          priceId,
          successUrl: `${window.location.origin}/payment/success`,
          cancelUrl: `${window.location.origin}/pricing`,
        }),
      });

      const { url, error } = await response.json();

      if (error) {
        console.error('Checkout error:', error);
        alert('Something went wrong. Please try again.');
        return;
      }

      // Redirect to Stripe Checkout
      window.location.href = url;
    } catch (err) {
      console.error('Network error:', err);
      alert('Network error. Please check your connection.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleCheckout}
      disabled={loading}
      className="bg-blue-600 text-white px-6 py-3 rounded-lg
                 hover:bg-blue-700 disabled:opacity-50"
    >
      {loading ? 'Loading...' : label}
    </button>
  );
}

Skip the boilerplate. Get PropelKit →

Step 6: Handle Webhooks

Webhooks are the most critical part of any Stripe integration. They're how Stripe tells your application that a payment succeeded, a subscription renewed, or a card was declined.

// src/app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import Stripe from 'stripe';

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

  if (!signature) {
    return NextResponse.json(
      { error: 'Missing stripe-signature header' },
      { status: 400 }
    );
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    );
  }

  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        await handleCheckoutComplete(session);
        break;
      }

      case 'invoice.paid': {
        const invoice = event.data.object as Stripe.Invoice;
        await handleInvoicePaid(invoice);
        break;
      }

      case 'customer.subscription.updated': {
        const subscription = event.data
          .object as Stripe.Subscription;
        await handleSubscriptionUpdated(subscription);
        break;
      }

      case 'customer.subscription.deleted': {
        const subscription = event.data
          .object as Stripe.Subscription;
        await handleSubscriptionCanceled(subscription);
        break;
      }

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Webhook handler error:', error);
    // Return 200 to prevent Stripe from retrying
    // Log the error for investigation
    return NextResponse.json({ received: true });
  }
}

async function handleCheckoutComplete(
  session: Stripe.Checkout.Session
) {
  const customerEmail = session.customer_details?.email;
  const subscriptionId = session.subscription as string;

  // Save to your database
  // Example with Supabase:
  // await supabase.from('subscriptions').insert({
  //   user_email: customerEmail,
  //   stripe_subscription_id: subscriptionId,
  //   stripe_customer_id: session.customer as string,
  //   status: 'active',
  // });

  console.log(`Checkout completed for ${customerEmail}`);
}

async function handleInvoicePaid(invoice: Stripe.Invoice) {
  // Update subscription period, send receipt email, etc.
  console.log(`Invoice paid: ${invoice.id}`);
}

async function handleSubscriptionUpdated(
  subscription: Stripe.Subscription
) {
  // Handle plan changes, update database
  console.log(
    `Subscription updated: ${subscription.id}, status: ${subscription.status}`
  );
}

async function handleSubscriptionCanceled(
  subscription: Stripe.Subscription
) {
  // Revoke access, send cancellation email
  console.log(`Subscription canceled: ${subscription.id}`);
}

Critical Webhook Best Practices

  1. Always verify the signature. Never process webhook events without verifying the stripe-signature header.

  2. Make handlers idempotent. Stripe may send the same event multiple times. Your handlers should produce the same result regardless of how many times they run.

  3. Return 200 quickly. Stripe expects a response within 20 seconds. Do heavy processing asynchronously.

  4. Handle errors without returning 500. If you return a 5xx status, Stripe will retry the webhook, which can cause duplicate processing.

  5. Use the raw body for verification. The request.text() method gives you the raw body needed for signature verification. Don't parse it as JSON first.

Step 7: Test Webhooks Locally

Use the Stripe CLI to forward webhooks to your local development server:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login to Stripe
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI will output a webhook signing secret (starting with whsec_). Use this as your STRIPE_WEBHOOK_SECRET in development.

You can trigger test events:

stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger customer.subscription.deleted

Step 8: Create a Customer Portal Route

Let users manage their subscriptions through Stripe's Customer Portal:

// src/app/api/billing/portal/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getUser } from '@/lib/auth';

export async function POST() {
  const user = await getUser();

  if (!user) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  // Look up the Stripe customer ID from your database
  // const { data } = await supabase
  //   .from('subscriptions')
  //   .select('stripe_customer_id')
  //   .eq('user_id', user.id)
  //   .single();

  const portalSession =
    await stripe.billingPortal.sessions.create({
      customer: 'cus_xxx', // Replace with actual customer ID
      return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
    });

  return NextResponse.json({ url: portalSession.url });
}

Step 9: Handle the Success Page

After checkout, redirect users to a success page that verifies the session:

// src/app/payment/success/page.tsx
import { stripe } from '@/lib/stripe';
import { redirect } from 'next/navigation';

interface Props {
  searchParams: Promise<{ session_id?: string }>;
}

export default async function PaymentSuccessPage({
  searchParams,
}: Props) {
  const { session_id } = await searchParams;

  if (!session_id) {
    redirect('/');
  }

  const session = await stripe.checkout.sessions.retrieve(
    session_id
  );

  if (session.payment_status !== 'paid') {
    redirect('/pricing?error=payment_failed');
  }

  return (
    <div className="max-w-md mx-auto mt-20 text-center">
      <h1 className="text-3xl font-bold">
        Payment Successful!
      </h1>
      <p className="mt-4 text-gray-600">
        Thank you for your purchase. You will receive a
        confirmation email shortly.
      </p>
    </div>
  );
}

Common Pitfalls to Avoid

1. Not Handling Race Conditions

Webhooks can arrive before or after the user is redirected to your success page. Design your system to handle both scenarios.

2. Exposing Secret Keys

Never use NEXT_PUBLIC_ prefix for your Stripe secret key. Only the publishable key should be exposed to the client.

3. Not Validating Webhook Signatures

Always verify the Stripe signature. Without verification, anyone can send fake events to your webhook endpoint.

4. Ignoring Failed Payments

Handle invoice.payment_failed events to notify users and implement grace periods before revoking access.

5. Not Testing Edge Cases

Test scenarios like:

  • User closes the browser during payment
  • Card is declined after 3D Secure
  • Subscription upgrade/downgrade mid-cycle
  • Webhook delivery fails and retries

Production Checklist

Before going live, make sure you:

  • Switch from test keys to live keys
  • Set up the production webhook endpoint in Stripe Dashboard
  • Configure the Customer Portal in Stripe Dashboard
  • Enable fraud protection (Stripe Radar)
  • Set up Stripe Tax if selling internationally
  • Test the complete flow with a real card (use Stripe test mode first)
  • Monitor webhook delivery in the Stripe Dashboard
  • Set up alerts for failed payments

Skip the Setup with PropelKit

If this guide felt like a lot of work, that's because it is. Integrating Stripe properly — with webhooks, error handling, race conditions, and security — takes a solid week of development time.

PropelKit includes all of this pre-built and tested:

  • Stripe Checkout integration with one-time and subscription support
  • Webhook handling with signature verification and idempotent processing
  • Customer Portal integration for self-service subscription management
  • Invoice generation with automatic PDF creation
  • Multi-gateway support — Stripe + Razorpay + DodoPayments
  • Rate limiting on all payment endpoints
  • Error monitoring with Sentry

Stop rebuilding payment infrastructure for every project. Get PropelKit and focus on what makes your product unique.

Ready to ship your SaaS?

PropelKit gives you everything you need — auth, payments, AI tools, multi-tenancy, and more. Go from idea to revenue in a day.

Get PropelKit
Share:

Related articles