Skip to main content
← Back to Blog
Tutorial

Building Custom Stripe Checkout Flows in Next.js

A
Abe Reyes
February 6, 202610 min read

Building Custom Stripe Checkout Flows in Next.js

Stripe's documentation is excellent for getting started. Their examples show you how to create a basic checkout session, redirect to Stripe's hosted page, and handle the webhook. For a simple "buy one product" flow, this works perfectly.

But real-world applications need more. What happens when you need to collect a 50% deposit upfront and charge the balance later? What if your cart contains both one-time products and recurring subscriptions? What if you need a completely custom UI that doesn't look like Stripe's hosted checkout?

I've built these flows for NeedThisDone.com and client projects. Here's what actually works in production.

Why Stripe's Examples Fall Short

Stripe's documentation optimizes for the happy path: one product, one payment, done. That's fine for selling digital downloads or simple SaaS subscriptions. But most businesses need more complexity:

Mixed cart scenarios: A customer wants a website package (one-time payment), a managed AI subscription (recurring), and a few add-ons (one-time). Stripe's hosted checkout doesn't handle mixed line items well out of the box.

Split payment flows: Many service businesses collect deposits upfront and balance payments later. Stripe's examples don't show how to track which payment is which, how to associate both with the same order, or how to handle the case where the deposit succeeds but the customer never comes back to pay the balance.

Custom payment forms: Sometimes you need full control over the UI. Maybe you're building a native mobile app. Maybe your checkout needs to match a strict brand design system. Stripe Elements gives you this control, but integrating them with Next.js app router patterns requires understanding both Stripe's client-side SDK and Next.js server components.

The gap between Stripe's examples and production requirements is where most developers get stuck. Let's bridge that gap.

Architecture: Server Actions vs API Routes

Next.js gives you two ways to handle server-side logic: API routes and server actions. For Stripe integration, the choice matters.

API routes (app/api/checkout/route.ts) work well for webhooks and any logic that needs to be called from external services. They're also easier to test with tools like Postman or curl.

Server actions (functions marked with 'use server') are perfect for form submissions and client-triggered server logic. They reduce boilerplate and give you better TypeScript integration with client components.

Here's when I use each:

// API Route - Good for webhooks, external calls
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const sig = request.headers.get('stripe-signature');
  const body = await request.text();
  const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
  // Handle event
}

// Server Action - Good for form submissions
// app/actions/checkout.ts
'use server';
export async function createCheckoutSession(formData: FormData) {
  const items = JSON.parse(formData.get('items') as string);
  const session = await stripe.checkout.sessions.create({
    line_items: items,
    mode: 'payment',
    success_url: `${process.env.NEXT_PUBLIC_URL}/success`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/cancel`,
  });
  return { url: session.url };
}

For this guide, I'll use API routes since they're easier to test and more explicit about what's happening. The patterns translate directly to server actions if you prefer that approach.

Scenario 1: Deposit + Balance Payment

Many service businesses operate on deposits: 50% upfront, 50% on delivery. Here's how to implement this with Stripe checkout sessions.

The strategy: Create two separate checkout sessions, but link them with metadata. The first session collects the deposit. When that succeeds, store the payment intent ID. Later, when you're ready to collect the balance, create a second session that references the original order.

// Create deposit payment session
const depositSession = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [
    {
      price_data: {
        currency: 'usd',
        product_data: { name: 'Deposit - Website Package' },
        unit_amount: 25000, // $250.00 deposit
      },
      quantity: 1,
    },
  ],
  metadata: {
    order_id: orderId,
    payment_type: 'deposit',
    deposit_percent: '50',
    total_amount: '50000', // Track full amount for balance calculation
  },
  success_url: `${baseUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${baseUrl}/checkout/cancel`,
});

// Later, create balance payment session
const balanceSession = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [
    {
      price_data: {
        currency: 'usd',
        product_data: { name: 'Balance Payment - Website Package' },
        unit_amount: 25000, // $250.00 balance
      },
      quantity: 1,
    },
  ],
  metadata: {
    order_id: orderId,
    payment_type: 'balance',
    deposit_session_id: depositSession.id,
  },
  success_url: `${baseUrl}/checkout/balance-success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${baseUrl}/checkout`,
});

Key insight: The metadata field is your friend. Use it to track order IDs, payment types, and any other context you need in the webhook handler. Stripe preserves this metadata through the entire payment lifecycle.

In your webhook handler, check the payment type and update your order accordingly:

if (event.type === 'checkout.session.completed') {
  const session = event.data.object;
  const { order_id, payment_type } = session.metadata;

  if (payment_type === 'deposit') {
    await updateOrder(order_id, { deposit_paid: true, deposit_session_id: session.id });
  } else if (payment_type === 'balance') {
    await updateOrder(order_id, { balance_paid: true, status: 'paid_in_full' });
  }
}

This pattern scales to any split payment scenario: thirds, custom percentages, milestone-based payments. The metadata approach keeps everything trackable.

Scenario 2: Mixed Cart (Products + Subscriptions)

Here's a problem Stripe's examples don't address: what if your cart contains both one-time products and recurring subscriptions?

Stripe checkout sessions have a mode parameter that's either 'payment' (one-time) or 'subscription' (recurring). You can't mix them in a single session.

The workaround: Create two separate checkout sessions and handle them sequentially or give the customer a choice.

Option A - Sequential checkouts: Charge the one-time products first, then redirect to a subscription setup session. This works but requires careful state management to avoid charging twice if something fails.

Option B - Subscription with setup fee: Convert your one-time products into a subscription setup fee. This only works if the recurring subscription is the primary product.

const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [
    {
      price: 'price_recurring_managed_ai', // Recurring subscription
      quantity: 1,
    },
  ],
  subscription_data: {
    metadata: {
      order_id: orderId,
    },
    // Add setup fee for one-time products
    trial_settings: {
      end_behavior: {
        missing_payment_method: 'pause',
      },
    },
  },
  invoice_data: {
    // One-time charge alongside subscription
    custom_fields: [
      { name: 'Setup Fee', value: '$500.00' },
    ],
  },
  metadata: {
    order_id: orderId,
    has_one_time_items: 'true',
    one_time_total: '50000',
  },
  success_url: `${baseUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${baseUrl}/checkout`,
});

Option C - Separate buttons: Show the customer two checkout buttons: "Buy Products ($500)" and "Subscribe ($99/mo)". This is the most transparent approach but requires explaining why they need two transactions.

I've found Option C works best for user experience. Customers understand "pay for the product, then subscribe" better than hidden setup fees or sequential charges.

Scenario 3: Custom Payment Forms with Stripe Elements

Sometimes you need full UI control. Maybe Stripe's hosted checkout doesn't match your design system. Maybe you need to embed the payment form in a multi-step wizard. Stripe Elements is the answer.

Elements are React components that securely collect card details without PCI compliance headaches. They render Stripe-hosted iframes that look native to your app.

Here's the setup:

// Install dependencies
// npm install @stripe/stripe-js @stripe/react-stripe-js

// components/CheckoutForm.tsx
'use client';
import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';

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

function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    setIsLoading(true);

    // Confirm payment with Stripe
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/checkout/success`,
      },
    });

    if (error) {
      console.error(error.message);
    }
    setIsLoading(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <button type="submit" disabled={!stripe || isLoading}>
        {isLoading ? 'Processing...' : 'Pay Now'}
      </button>
    </form>
  );
}

export default function CheckoutPage({ clientSecret }: { clientSecret: string }) {
  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm />
    </Elements>
  );
}

The key is the clientSecret: you generate this server-side with a payment intent, then pass it to the Elements provider. Elements uses it to securely submit payment details to Stripe.

Server-side setup:

// app/api/payment-intent/route.ts
export async function POST(request: Request) {
  const { amount, orderId } = await request.json();

  const paymentIntent = await stripe.paymentIntents.create({
    amount,
    currency: 'usd',
    metadata: { order_id: orderId },
  });

  return Response.json({ clientSecret: paymentIntent.client_secret });
}

Custom forms give you total control but require more code. Use them when Stripe's hosted checkout UI can't meet your requirements.

Webhook Handling Done Right

Webhooks are where most Stripe integrations break in production. Stripe retries failed webhooks for three days, so you need idempotency and proper error handling.

Production-grade webhook handler:

// app/api/webhooks/stripe/route.ts
import { supabaseRetry } from '@/lib/supabase-retry';
import { headers } from 'next/headers';

const relevantEvents = new Set([
  'checkout.session.completed',
  'payment_intent.succeeded',
  'payment_intent.payment_failed',
  'customer.subscription.created',
  'customer.subscription.updated',
  'customer.subscription.deleted',
]);

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

  let event: Stripe.Event;

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

  // Ignore irrelevant events
  if (!relevantEvents.has(event.type)) {
    return Response.json({ received: true });
  }

  // Idempotency check - have we processed this event already?
  const { data: existing } = await supabase
    .from('webhook_events')
    .select('id')
    .eq('event_id', event.id)
    .single();

  if (existing) {
    console.log(`Event ${event.id} already processed`);
    return Response.json({ received: true });
  }

  // Process the event with retry logic
  try {
    await supabaseRetry(async () => {
      // Store event first for idempotency
      await supabase.from('webhook_events').insert({
        event_id: event.id,
        type: event.type,
        processed_at: new Date().toISOString(),
      });

      // Handle specific event types
      switch (event.type) {
        case 'checkout.session.completed':
          await handleCheckoutCompleted(event.data.object);
          break;
        case 'payment_intent.succeeded':
          await handlePaymentSucceeded(event.data.object);
          break;
        // ... other cases
      }
    });

    return Response.json({ received: true });
  } catch (error) {
    console.error('Webhook processing failed:', error);
    // Return 500 so Stripe retries
    return new Response('Webhook processing failed', { status: 500 });
  }
}

Key patterns:

  1. Verify signatures: Always verify the webhook signature. This prevents attackers from sending fake events to your endpoint.
  2. Idempotency tracking: Store processed event IDs in your database. If Stripe retries, you won't double-process.
  3. Retry logic: Use exponential backoff for database operations. I use a supabaseRetry utility (see Building Circuit Breaker Pattern in Node.js for the implementation).
  4. Return 200 quickly: Process the event, then return success. Don't do slow operations in the webhook handler itself—queue them for background processing.

Testing Strategy

Test Stripe integration thoroughly before going live. Here's my process:

1. Use test mode religiously: Stripe's test mode gives you fake card numbers that simulate success, failure, 3D Secure, and other scenarios. Never test with real cards.

2. Stripe CLI for webhooks: Install the Stripe CLI and forward webhooks to your local dev server:

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

This lets you trigger webhooks locally without deploying to production.

3. Integration tests: Write tests that hit your checkout API with test mode credentials:

// __tests__/checkout.test.ts
import { createCheckoutSession } from '@/app/actions/checkout';

test('creates deposit checkout session', async () => {
  const session = await createCheckoutSession({
    orderId: 'test_order_123',
    amount: 25000,
    paymentType: 'deposit',
  });

  expect(session.url).toContain('checkout.stripe.com');
  expect(session.metadata.payment_type).toBe('deposit');
});

4. End-to-end tests: Use Playwright with Stripe test cards to test the full checkout flow:

test('complete checkout flow', async ({ page }) => {
  await page.goto('/checkout');
  await page.fill('[name="email"]', 'test@example.com');
  await page.fill('[name="card"]', '4242 4242 4242 4242');
  await page.fill('[name="exp"]', '12/34');
  await page.fill('[name="cvc"]', '123');
  await page.click('button[type="submit"]');
  await page.waitForURL('/checkout/success');
  expect(page.url()).toContain('success');
});

Testing Stripe thoroughly catches edge cases before customers hit them. I've caught bugs around failed payments, expired sessions, and webhook retries this way.

Get Help with Complex Checkouts

Building production-ready Stripe integrations takes time. If you need deposit flows, mixed carts, custom UIs, or webhook handlers that don't break, I can help.

I've built these patterns for NeedThisDone.com and client projects. The code is battle-tested in production handling real payments.

See examples of my work on the portfolio page, or check out the services page for what I offer. Want to discuss your project? Get in touch.

Related reading: Request Deduplication: Preventing Double Submissions covers the idempotency patterns that keep webhooks safe.

Need Help Getting Things Done?

Whether it's a project you've been putting off or ongoing support you need, we're here to help.