Stripe Integration in Next.js

12. April, 2025 10 min read Develop

The part you only get to test in production once

Payments are the one feature where a bug does not produce a bad user experience, it produces a bank statement. Everything else in a web app can be fixed on Tuesday. A double charge cannot, and nor can an order that was paid for and never shipped because a request timed out at the wrong moment.

Stripe is the obvious choice and I’m not going to pretend otherwise. What I want to write about is the shape of a Next.js integration that survives contact with reality, which mostly means: the checkout session is the easy half, and the webhook is the actual integration.

Stripe shipped a new API version on 31 March, 2025-03-31.basil, and the Node SDK went to 18.0.0 the day after to match. There are two changes in there that will quietly break a subscription integration you copied from a tutorial written last year, so I’ll come back to those. For the database side I’m assuming something like the setup from Using Prisma with Next.js.

Two ways in, and only one of them is worth starting with

Stripe gives you Checkout, which is a page they host and maintain, and Elements plus the Payment Intents API, which is a set of components you assemble into your own form.

Start with Checkout. It handles Strong Customer Authentication, Apple Pay and Google Pay, local payment methods, address collection, tax and receipts, and every one of those is a thing you would otherwise have to build and then keep working. The usual objection is branding, and it’s a weak one: you can set a logo, colours and a domain, and the customers who abandon carts are not abandoning them over the shade of your button.

Move to Elements when you have a concrete reason, like a checkout flow with steps that have to happen between card entry and confirmation. Not before.

Keys, versions and the pin nobody sets

npm install stripe @stripe/stripe-js

stripe is the server SDK. @stripe/stripe-js is the browser loader, and for a plain hosted-Checkout redirect you do not actually need it at all, which surprised me. It earns its place once you use Elements or embedded Checkout.

# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

Only the NEXT_PUBLIC_ one reaches the browser, which is exactly right: the publishable key is meant to be public, the other two are not. Next.js will happily inline anything prefixed that way into the client bundle, so mis-prefixing the secret key ships it to every visitor. Worth a second look before you commit.

Pin the API version explicitly:

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

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-03-31.basil',
});

Without the pin you get whatever version your account defaults to, which means a Stripe dashboard setting can change your application’s behaviour without a deploy. Pin it, and upgrade deliberately when you have time to read the changelog.

Creating the session

One route handler, one job:

// app/api/checkout/route.ts
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { auth } from '@/lib/auth';

export async function POST(request: Request) {
  const session = await auth.api.getSession({ headers: await headers() });

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

  const { priceId, quantity = 1 } = await request.json();
  const origin = request.headers.get('origin') ?? 'http://localhost:3000';

  const checkout = await stripe.checkout.sessions.create(
    {
      mode: 'payment',
      line_items: [{ price: priceId, quantity }],
      client_reference_id: session.user.id,
      customer_email: session.user.email,
      success_url: `${origin}/order/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${origin}/cart`,
    },
    {
      idempotencyKey: `checkout:${session.user.id}:${priceId}:${quantity}`,
    }
  );

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

The session lookup is Better Auth, which I wrote about last month; swap in whatever you use. A few other things in there that the short examples leave out.

Use price with a Price ID created in the dashboard rather than inline price_data. Inline prices mean your catalogue lives in your source code, and the first time someone asks you to run a discount you’ll wish it didn’t. price_data is fine for genuinely dynamic amounts, and nothing else.

client_reference_id is the field that ties the Stripe session back to your user. It comes back on the webhook event. Without it you’re matching on email addresses, which works right up until someone checks out with a different one.

{CHECKOUT_SESSION_ID} is a literal template token that Stripe substitutes, not a JavaScript placeholder. It looks like a typo in every code sample and it isn’t.

The idempotencyKey means a double-clicked button creates one session rather than two. Stripe replays the original response for 24 hours. It costs nothing and removes a whole category of support ticket.

Redirecting

// components/checkout-button.tsx
'use client';

import { useState } from 'react';

export function CheckoutButton({ priceId }: { priceId: string }) {
  const [pending, setPending] = useState(false);

  async function handleClick() {
    setPending(true);

    const response = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId }),
    });

    if (!response.ok) {
      setPending(false);
      return;
    }

    const { url } = await response.json();
    window.location.href = url;
  }

  return (
    <button onClick={handleClick} disabled={pending}>
      {pending ? 'One moment…' : 'Checkout'}
    </button>
  );
}

That’s it. No Stripe.js, no loadStripe, no promise held in module scope.

If you’ve seen stripe.redirectToCheckout({ sessionId }) in older posts, that is the legacy path. It still exists in @stripe/stripe-js, but Stripe’s own quickstarts have used session.url for years now, and the url approach is one fewer network round trip and one fewer script on your page.

The webhook is the integration

Here is the thing that separates a demo from something you can charge money with: do not fulfil the order on the success page. The customer’s browser can close, crash, lose signal or simply never follow the redirect, and the payment still went through. Stripe knows about the payment. Your database does not.

The success page is a receipt. The webhook is the truth.

// app/api/stripe/webhook/route.ts
import type Stripe from 'stripe';
import { stripe } from '@/lib/stripe';
import { fulfillOrder } from '@/lib/orders';

export const runtime = 'nodejs';

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.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 (error) {
    return new Response(`Invalid signature: ${(error as Error).message}`, {
      status: 400,
    });
  }

  switch (event.type) {
    case 'checkout.session.completed':
      await fulfillOrder(event.id, event.data.object);
      break;

    case 'checkout.session.async_payment_failed':
    case 'payment_intent.payment_failed':
      // notify, release stock, whatever your domain needs
      break;

    default:
      break;
  }

  return new Response(null, { status: 200 });
}

Four details worth spelling out.

Read the raw body. await request.text(), not request.json(). The signature is computed over the exact bytes Stripe sent, so parsing and re-serialising invalidates it. In the App Router this is easy; in the old Pages Router you had to disable the body parser with export const config = { api: { bodyParser: false } } and buffer the stream yourself. Not having to do that is one of the better reasons to be on the App Router.

Pin the runtime to Node. constructEvent uses Node’s crypto. On an edge runtime you want constructEventAsync instead, which goes through the Web Crypto API.

Verify before you trust. The endpoint is public. Anyone can POST to it. Without signature verification, “please mark order 42 as paid” is a curl command.

Handle duplicates. Stripe retries on any non-2xx response, and it will occasionally deliver the same event twice for reasons entirely on its side. event.id is stable, so store it and make fulfilment a no-op the second time:

export async function fulfillOrder(eventId: string, session: Stripe.Checkout.Session) {
  const created = await db.processedEvent.createMany({
    data: { id: eventId },
    skipDuplicates: true,
  });

  if (created.count === 0) return; // already handled

  await db.order.update({
    where: { id: session.client_reference_id! },
    data: { status: 'paid', paymentIntentId: session.payment_intent as string },
  });
}

And return quickly. Stripe’s delivery has a timeout, and a slow handler starts a retry storm. If fulfilment involves sending mail or talking to a warehouse, acknowledge first and queue the work.

Testing it locally

The webhook is unreachable from the internet on localhost, so the CLI tunnels for you:

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook

stripe listen prints a whsec_... secret on startup. That is the one to put in .env.local while developing; it is not the same as the endpoint secret from the dashboard, which is the one you need in production. I have lost time to that twice.

To fire an event without going through the flow:

stripe trigger checkout.session.completed

The card numbers, once you’re in test mode:

Card Expiry, CVC Result
4242 4242 4242 4242 any future, any Succeeds immediately
4000 0027 6000 3184 any future, any Forces a 3D Secure challenge
4000 0000 0000 0002 any future, any Declined, generic_decline
4000 0000 0000 9995 any future, any Declined, insufficient_funds

Test the 3D Secure one. European cards increasingly require it, and a flow that dead-ends on the authentication modal looks fine on your machine and loses you every customer in the EU.

What Basil changed

Two breaking changes in 2025-03-31.basil are worth knowing before they find you.

Subscriptions are created after payment now. Previously, creating a subscription-mode Checkout Session created the subscription up front. Now Stripe waits until the customer has actually paid. This is a genuine improvement (it fixes the case where a failed first payment left a customer unable to update their billing details) but it means session.subscription and session.invoice are null until the session completes. Any code that reads those straight after sessions.create now reads nothing. Move it to the checkout.session.completed handler.

current_period_end moved. It used to live on the Subscription. In Basil it lives on each subscription item, because items in a single subscription can be on different billing cycles:

const subscription = await stripe.subscriptions.retrieve(subscriptionId);

// used to work, doesn't any more:
// const renewsAt = subscription.current_period_end;

const renewsAt = subscription.items.data[0].current_period_end;

Every “gate the feature until the period ends” implementation written before April 2025 reads undefined here, and undefined compared against a timestamp is false, so the failure mode is “everyone’s subscription looks expired”. Delightful.

Also gone: expanding total_count on list responses. If you were paginating with it, you now count differently.

The bit that still bothers me

Amounts are integers in the currency’s smallest unit. unit_amount: 2000 is twenty francs, not two thousand. Everyone knows this and everyone has still, at some point, written unit_amount: price where price was already in francs and charged someone one hundredth of what they owed. The API cannot catch it because both values are valid integers.

The only thing that has reliably worked for me is refusing to let a bare number cross the boundary. Wrap it, name it amountInCents, make the type system carry the unit, and validate the total against a server-side price lookup before creating the session. Never send an amount from the client. It is a form field, and form fields lie.

What I’d add next

Reconciliation. Everything above is correct as long as webhooks arrive, and mostly they do, but “mostly” is not a word I enjoy near an order table. A nightly job that lists Stripe’s completed sessions for the last day and compares them against local orders would close the gap, and I have not written it yet.

Also worth a look is embedded Checkout via ui_mode: 'embedded', which keeps the customer on your domain and returns a client_secret instead of a url. Slightly more work, noticeably less jarring.

‘Till next time!