Skip to content

E-commerce

How to Build a Secure Stripe Checkout Flow in Next.js 16 Using Checkout Sessions

September 24, 20269 min readRasel Hossain
How to Build a Secure Stripe Checkout Flow in Next.js 16 Using Checkout Sessions

Quick answer

How to Build a Secure Stripe Checkout Flow in Next.js 16 Using Checkout Sessions - A comprehensive guide by Rasel Hossain

What is a Stripe Checkout Session and why should you create it server-side?

A Checkout Session encapsulates everything Stripe needs to display a payment form: line items, pricing, tax, shipping, and redirect URLs. Creating it on the server guarantees that your secret API key never touches the client, lets you validate inventory or user entitlements before exposing a price, and provides a natural place to add an idempotency key so duplicate requests don’t spawn multiple charges. In short, server‑side session creation is the foundation of a PCI‑compliant, scalable checkout flow.

data theft, data, dvd, password, security, computer, pc, protection, espionage, secure, code, programming, attack, viruses, web, dvd, dvd, dvd, dvd, dvd

How do I set up the Next.js 16 project for Stripe?

First, install the official Stripe library and configure environment variables:

npm i stripe

Create a .env.local file (never commit this):

STRIPE_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXXXXXXXX
STRIPE_WEBHOOK_SECRET=whsec_XXXXXXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_XXXXXXXXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_APP_URL=http://localhost:3000

Next, initialize a singleton Stripe client in a utility file (lib/stripe.ts):

import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
  // Register app info for Stripe dashboard insights
  appInfo: {
    name: 'raselhossain.dev',
    version: '1.0.0',
  },
});

With the client ready, we can build the API route that will create the Session.

How to create a Checkout Session on the server with idempotency?

In Next.js 16, the preferred place for server‑only code is the app/api directory using the Route Handlers syntax. Below is a POST /app/api/create-checkout-session/route.ts that receives a cart payload, validates it, and returns a session URL.

import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';

export async function POST(request: Request) {
  try {
    const { cartItems, customerId } = await request.json();

    // Basic validation – adjust to your schema
    if (!Array.isArray(cartItems) || cartItems.length === 0) {
      return NextResponse.json({ error: 'Invalid cart' }, { status: 400 });
    }

    // Build line items from cart
    const lineItems = cartItems.map(item => ({
      price_data: {
        currency: 'usd',
        product_data: {
          name: item.name,
          metadata: { productId: item.id },
        },
        unit_amount: Math.round(item.price * 100), // cents
      },
      quantity: item.qty,
    }));

    // Idempotency key: hash of cart + customer + timestamp (or use UUID)
    const idempotencyKey = Buffer.from(
      JSON.stringify({ cartItems, customerId, ts: Date.now() })
    ).toString('base64');

    const session = await stripe.checkout.sessions.create(
      {
        payment_method_types: ['card'],
        line_items: lineItems,
        mode: 'payment',
        success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
        cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/cancel`,
        // Attach customer if you have one; otherwise Stripe creates a new one
        ...(customerId && { customer: customerId }),
        metadata: {
          // Pass any custom data you need in the webhook
          cart: JSON.stringify(cartItems),
        },
      },
      { idempotencyKey }
    );

    return NextResponse.json({ url: session.url });
  } catch (err: any) {
    console.error('Stripe session error:', err);
    return NextResponse.json(
      { error: err.message ?? 'Unexpected error' },
      { status: 500 }
    );
  }
}

Why the idempotency key matters: If a user clicks “Pay” twice or a network retry occurs, Stripe will see the same key and return the existing Session instead of creating a duplicate charge. This prevents double‑billing and keeps your analytics clean.

How to handle redirects and success/cancel pages?

After you obtain the session.url from the API, redirect the client:

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

import { useState } from 'react';

export default function CheckoutButton({ cart }: { cart: any[] }) {
  const [loading, setLoading] = useState(false);

  async function handleClick() {
    setLoading(true);
    const res = await fetch('/api/create-checkout-session', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ cartItems: cart, customerId: getCustomerId() }),
    });
    const data = await res.json();
    if (data.url) {
      window.location.href = data.url;
    } else {
      alert(data.error ?? 'Something went wrong');
    }
    setLoading(false);
  }

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? 'Processing…' : 'Pay with Card'}
    </button>
  );
}

Create the success and cancel pages under app/checkout/(pages):

  • Success (page.tsx): Retrieve the session ID from the query string, verify it via Stripe (optional but recommended), then show a thank‑you message and trigger fulfillment.
  • Cancel (page.tsx): Simply inform the user that the payment wasn’t completed and let them return to the cart.

How to verify webhooks securely?

Webhooks are the only reliable way to know when a payment has actually succeeded, because the client‑side redirect can be tampered with or the user might close the browser before hitting the success page. Set up a POST route at /app/api/stripe-webhook/route.ts:

import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';

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

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

  // Handle the event
  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object as Stripe.Checkout.Session;
      await fulfillOrder(session);
      break;
    case 'payment_intent.payment_failed':
      // You could notify the user via email or mark the order as failed
      break;
    default:
      console.log(`Unhandled event type ${event.type}`);
  }

  // Return a 200 response to acknowledge receipt
  return NextResponse.json({ received: true });
}

Key security points:

  1. Signature verification – Never skip constructing the event with your webhook secret.
  2. Idempotent handling – Store the Stripe event ID in a database; if you see it again, skip processing.
  3. Async fulfillment – Offload heavy work (e.g., sending emails, updating inventory) to a background queue if possible, but keep the webhook handler fast (< 2 seconds) to avoid Stripe retries.

How to fulfill orders after payment?

In the fulfillOrder function above, you’ll typically:

  1. Retrieve the session and expand line items to get product IDs.
  2. Check your inventory or digital asset availability.
  3. Create an order record in your DB with status processing.
  4. For physical goods: trigger a shipment workflow or notify your fulfillment team.
  5. For digital goods: generate a secure download link or provision access.
  6. Send a confirmation email (using SendGrid, SES, etc.).
  7. Update the order status to completed.

Here’s a simplified example using Prisma:

import { prisma } from '@/lib/prisma';

async function fulfillOrder(session: Stripe.Checkout.Session) {
  const { metadata, amount_total, id: sessionId } = session;
  const cart = JSON.parse(metadata.cart || '[]');

  // Prevent duplicate processing
  const existing = await prisma.order.findFirst({
    where: { stripeSessionId: sessionId },
  });
  if (existing) return;

  const order = await prisma.order.create({
    data: {
      userId: session.metadata?.userId ?? null,
      amount: amount_total / 100, // convert cents to dollars
      currency: session.currency,
      stripeSessionId: sessionId,
      status: 'processing',
      items: {
        create: cart.map((item: any) => ({
          productId: item.productId,
          quantity: item.qty,
          price: item.price,
        })),
      },
    },
  });

  // Example: send email
  await sendOrderConfirmationEmail(order);
  // Update inventory
  await decrementInventory(cart);
  // Mark as completed
  await prisma.order.update({
    where: { id: order.id },
    data: { status: 'completed' },
  });
}

Common pitfalls and how to avoid them

| Pitfall | Symptom | Fix | |---------|---------|-----| | Creating Session in the browser | Secret key exposed, inconsistent pricing | Always call your own API route; never expose sk_* | | Missing idempotency key | Duplicate charges on retry | Generate a stable key from cart + customer + timestamp | | Skipping webhook verification | Fake events can trigger fulfillment | Verify signature with stripe.webhooks.constructEvent | | Assuming success page = payment succeeded | User closes browser before webhook fires | Rely on webhook for order state; success page is UX only | | Hardcoding URLs | Breaks when deploying to different environments | Use NEXT_PUBLIC_APP_URL and relative paths where possible | | Ignoring pending payments (e.g., SEPA, iDEAL) | Order marked completed too early | Check session.payment_status in webhook; only fulfill when paid |

Quick How-To: Building the Flow (5 Steps)

  1. Install & configure – Add stripe package, set secret and webhook keys in .env.local.
  2. Create API route – POST /api/create-checkout-session that validates cart, builds line items, and returns a session URL with an idempotency key.
  3. Client redirect – Call the API from a button, then window.location.href = data.url.
  4. Set up webhook endpoint – POST /api/stripe-webhook to verify signatures and handle checkout.session.completed.
  5. Fulfill & respond – In the webhook handler, persist order, update inventory, send confirmation, and return 200.

Conclusion

Building a secure Stripe Checkout flow in Next.js 16 boils down to three pillars: server‑side session creation with idempotency, robust webhook verification, and deterministic fulfillment. By keeping your secret key off the client, protecting every webhook with a signature check, and treating the success page as merely a UX cue, you eliminate the most common sources of revenue leakage and fraud. The patterns I’ve shared have powered dozens of client stores—from single‑product landing pages to multi‑vendor marketplaces—delivering consistent conversion rates and zero payment‑related disputes. Apply them, test rigorously in Stripe’s test mode, and you’ll have a checkout that’s both trustworthy and scalable.

Let's Work Together

Ready to add a rock‑solid Stripe checkout to your Next.js store? I’m Rasel Hossain, a Full‑Stack Developer with 6+ years of experience and 168+ successful Fiverr projects. Let’s discuss your e‑commerce needs and build a solution that converts.

Email | WhatsApp | Phone: +8801757220402

More articles

Related reading from the same areas — practical notes on shipping software.

View all articles

Liked the article?

Have a similar problem in your business? Let's talk about building the fix.

Start a project