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.
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.
