Skip to content

A Stripe Checkout That Does Not Trust the Browser

The client confirms the payment and the client must never create the order. Where each piece of a checkout belongs in an App Router application, and which of them can be tampered with.

6 min read

A checkout has three participants: the customer's browser, your application, and the payment provider. Getting it right is almost entirely a question of which of them is allowed to assert what, and the browser is allowed to assert very little.

In an App Router application that division lands on specific files, and it is worth writing down before any of them exist.

What the server decides

The customer's browser sends product ids and quantities. That is the extent of what it may contribute.

The price, the tax, the shipping, the discount and the final amount are computed server side from your own data, every time, including on the request that creates the payment. Not read from a hidden field, not taken from a client-side cart total, not trusted because the previous request calculated it correctly.

// app/checkout/actions.ts
'use server'
 
export async function startCheckout(items: CartItem[]) {
  const session = await auth()
  const priced = await priceBasket(items)        // server prices, server rules
  const order = await db.order.create({
    data: { userId: session.user.id, status: 'pending', total: priced.total },
  })
 
  const intent = await stripe.paymentIntents.create({
    amount: priced.total,                        // minor units, from the server
    currency: priced.currency,
    metadata: { orderId: order.id },
    automatic_payment_methods: { enabled: true },
  })
 
  return { clientSecret: intent.client_secret, orderId: order.id }
}

Three things in there are deliberate. The order exists before the payment does, in a pending state, so there is always something to attach the outcome to. Its id travels in the payment's metadata, which is what lets a webhook arriving forty minutes later find it without a lookup table. And the only thing returned to the browser is a client secret, which authorises confirming this one payment and nothing else.

What the client does, and what it does not

The browser collects the card and confirms the payment directly with Stripe. The card never touches your server, which is what keeps you out of the compliance regime that handling card data would put you in.

That confirmation is a client component - one of the few places on a commerce site where a client component is genuinely required rather than convenient, and worth the exception to keeping the boundary tight. It needs Stripe's JavaScript, it needs to mount an iframe, and it needs to react to the customer's bank.

What it does not do is tell your server the payment worked. The confirm call resolves in the browser, and a resolved promise in a browser is not a fact about money. It is a good reason to show a success screen; it is not a reason to fulfil an order.

The outcome arrives somewhere else

A Route Handler receives the webhook, verifies the signature against the raw body, and hands the event to something durable.

// app/api/stripe/webhook/route.ts
export async function POST(request: Request) {
  const raw = await request.text()                       // raw bytes, not json()
  let event
  try {
    event = stripe.webhooks.constructEvent(
      raw, request.headers.get('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!,
    )
  } catch {
    return new Response(null, { status: 400 })
  }
 
  await recordAndEnqueue(event.id, raw)                  // outside this process
  return new Response(null, { status: 200 })
}

request.json() would break the signature, because the hash is over the bytes that were sent and re-encoding an object does not reproduce them. And the handler stays short on purpose: fulfilment, email, stock decrements and accounting all happen after the response, on something that can retry.

Not in after, which runs the callback once the response is sent but still inside the same invocation and the same maximum duration as the route - it cannot be retried and it does not survive the instance going away. That is the same boundary that every third-party integration runs into, and payments are the case where crossing it costs real money.

The page in between

Between confirmation and the webhook there is a gap, usually seconds and occasionally minutes, and the customer is sitting on a page during it.

Render from your own order row rather than from anything the client reported. The order is pending, so the page says the payment is being confirmed and offers the order reference. When the webhook lands and the row flips to paid, the page shows the receipt.

The cheap mistake is to render success optimistically because the client promise resolved. It looks correct in development, where the webhook arrives in a few hundred milliseconds, and it produces a confirmed order screen for a payment that was declined at the last step. Show what your database knows; it is the only participant here that you control.

That intermediate state is a real screen with real copy, and the pattern for building it matters more on a payment than anywhere else on a site, because this is the moment a customer decides whether to try again and pay twice.

Where this leaves the architecture

The Next.js application owns the checkout screen, the pricing, the pending order and the webhook receiver. It does not own retries, reconciliation, or anything that has to happen on a schedule. Those belong to a worker or a backend service, for the same reason as always: a request ends, and a payment's story does not.

Our commerce work starts at this boundary rather than at the design, because a checkout that is beautiful and trusts the browser is a checkout that will be exploited within a month of launch.

Related questions

Can the amount be passed from the client?
It can be sent and it must not be believed. Anything reaching a Server Action or a Route Handler came from a browser that the customer controls, so the price has to be recomputed on the server from the product ids and the quantities. This is the oldest bug in online commerce and it still ships, because in development the value in the request is always the value you put there.
Do we need Stripe Elements or is the hosted page enough?
The hosted page is less work, keeps you further from card data and handles a lot of edge cases you would otherwise meet one at a time. Elements is worth it when the checkout is part of the product experience rather than a step at the end of it. Both keep the card out of your server, which is the property that actually matters.
Where does the order get created?
Server side, before the payment, in a pending state, with its own identifier carried into the payment's metadata. Creating it when the client reports success means no order exists for any customer whose browser closed, and creating it only from the webhook means you have nothing to show the customer on the page they are looking at right now.
Is a Server Action the right place for any of this?
For creating the intent, yes - it runs on the server, it can read the session, and it returns a client secret that is safe to hand to the browser. For anything that has to happen after the response, no. A Server Action is a request; it ends when the response is sent, and a payment does not.

Back to all articles