3D Secure Takes the Customer Off Your Page
Authentication sends the browser to a bank and back, and everything React was holding is gone. How to keep a payment's state where a redirect cannot destroy it, and what to render meanwhile.
A customer clicks pay. Their bank decides this payment needs authentication. The browser leaves your site for a page you have never seen, the customer approves it with an app or a code, and the browser comes back.
At that moment, every piece of React state your checkout was holding is gone. The component tree unmounted, the hooks reset, the variable holding which order this was does not exist. This is not a bug and there is nothing to fix in your code - a redirect is a navigation, and navigations destroy client state.
The checkout has to be built so that losing it costs nothing.
What has to exist before the customer leaves
Before the browser goes anywhere, the server must already hold everything needed to make sense of the return. In practice that means an order row, in a pending state, with an identifier of your own.
That identifier goes into two places. It goes into the payment's metadata at the
provider, so an event arriving later can find the order without a lookup table.
And it goes into the return_url as a path segment:
return_url: `https://example.com/orders/${order.id}/confirm`A path rather than a query string, because a path is a page you control and a query string is a parameter the customer can change. That URL is going to be visited by a browser after a round trip through a third party, and everything it carries should be a reference to something the server can verify rather than a claim it has to trust.
Nothing about the amount, the status or the customer goes in that URL. It names an order; the server decides what that order means.
The return page reads the database
The confirmation route is a Server Component, and it loads the order by the id in the path, checks it belongs to the person asking, and renders what the row says.
export default async function Confirm({ params }) {
const { id } = await params
const session = await auth()
const order = await db.order.findUnique({ where: { id } })
if (!order || order.userId !== session?.user?.id) notFound()
if (order.status === 'paid') return <Receipt order={order} />
if (order.status === 'failed') return <Retry order={order} />
return <Pending order={order} />
}The ownership check is not optional. Order identifiers end up in browser history, in shared links, in support tickets, and an id alone is not authorisation to view a receipt with someone's address on it.
Notice what the page does not look at: the query parameters the provider appended on the way back. Some of them do carry a status, and they are useful for choosing which message to show first, but they came through a browser and they are not what decides whether the order is paid.
The third state is the one that matters
Most teams build two outcomes, success and failure, and discover the third in production. The third is that the payment is real, the customer is here, and your database does not know the answer yet because the webhook has not landed.
It usually lasts a few seconds. Occasionally it lasts minutes, and that is the case the screen has to be designed for.
Poll the order row for a short while - a client component that re-checks a couple of times over ten or fifteen seconds covers almost every real case. Then stop. A page that spins forever teaches the customer that something broke, and the next thing they do is pay again, which turns a slow webhook into a duplicate charge and a refund.
So the pending screen, after the poll gives up, should say plainly that the payment was received and the confirmation is being finalised, show the order reference, and promise an email. That copy is doing real work: it is the difference between a customer who waits and a customer who pays twice.
The state that lives in the URL is the only state that survives
There is a broader principle here that applies well beyond payments, and this is the sharpest example of it.
Anything the application needs after a navigation it does not control has to
live somewhere the navigation cannot touch: the URL path, a cookie, or the
database. React state does not qualify. sessionStorage does not qualify,
because the customer may return in a different tab, on a different device, from
an email link. A component holding the answer does not qualify, because it will
not be mounted.
In practice this pushes checkout state onto the server, which is where App
Router wants it anyway. The caching question
then has one firm answer: these pages are never cached. An order confirmation is
per customer and changes under you; reading cookies() in the route makes it
dynamic, and here that is the behaviour you want rather than a cost.
The rest of the checkout - who computes the price, who may create the order, where the webhook lands - is the same division of trust, and the redirect is simply the moment it gets tested.
Our commerce engagements treat this screen as part of the payment work rather than part of the design work, because it is the last thing a customer sees before deciding whether the money went through.
