Shopping Cart Page Design: The Confirm Step Most Carts Skip
What each region of a cart page has to prove, a two-stage commit lifted from a real redemption picker, and the mobile reorder that needs no second button.
A shopping cart page has one job: let someone verify what they are about to buy and then commit without hesitating. Everything that earns a place on it either confirms a line item, removes a reason to stop, or moves the shopper to checkout. Everything else — cross-sells, banners, a second navigation — is competing with your own checkout button.
Most cart-page advice is a list of elements to include. That list is easy and it is not where carts lose money. The decisions that matter are structural: whether a cart page should exist at all, what order its regions appear in on a phone, and whether the irreversible step is visibly irreversible before it fires.
The regions, and what each one has to prove
| Region | The question it answers | Fails when |
|---|---|---|
| Line items | "Is this the thing I picked?" | The name is a SKU, or the thumbnail is a placeholder |
| Quantity / variant | "Can I fix a mistake here?" | Editing means going back to the product page |
| Price breakdown | "What is the real total?" | Shipping or tax appears first at checkout |
| Promo field | "Am I leaving money on the table?" | It is prominent enough to send shoppers hunting for a code |
| Trust row | "What happens if this is wrong?" | Returns and refund terms are a footer link |
| Primary CTA | "What do I do next?" | It shares visual weight with "continue shopping" |
| Empty state | "Where do I go now?" | It says "Your cart is empty" and nothing else |
The promo field is the interesting row, because it cuts both ways. It is on every best-practice list, and an open coupon input also tells a shopper that a better price exists somewhere — which is an invitation to go looking for it. Keep it, make it a collapsed link rather than an open input, and you have answered the question without asking it.
First, decide whether the page should exist
A dedicated cart page costs a navigation step. It buys room for an order summary, trust signals and upsells. A slide-out mini-cart costs that room and buys the shopper staying on the product grid.
The rule that holds up: the cart page earns its step when the order is composed of several decisions, and loses it when the purchase is one decision. Multi-item baskets, shipping choices, quantity edits — those need a page. A single-SKU, fixed-price purchase does not, and putting one behind a cart page adds a click to a flow that had nothing to review.
This storefront is the second case, and it has no cart at all. 110 available products, every purchase a fixed tier, and the buy path goes from the product page to a hosted checkout with no basket in between — a decision covered in e-commerce in React: the storefront with no shopping cart. That is not an argument against cart pages. It is the same rule applied: there is nothing to review, so there is no page.
Where this codebase does have a cart-shaped screen is the one place a purchase turns into several decisions — redeeming entitlement slots. A tier-2 or tier-3 buyer owns N template slots and spends them one at a time, which is a basket in everything but name: a list of choices, a limit, and a commit step. That screen is where the design decisions below were actually made, and they are the ones that transfer.
The decision cart pages get wrong: the commit step
A cart's primary button is the most consequential control on the page, and the standard design gives it no more weight than "continue shopping". Worse, when the action is irreversible — a final sale, a non-refundable item, a choice that cannot be swapped later — most carts communicate that in terms-of-service prose nobody reads, then fire on the first click.
The pattern that works is a two-stage commit: the first click reveals exactly what is about to happen and names the thing, the second click does it. Here is the real implementation from src/components/molecules/RedemptionPicker.tsx, unedited:
{!confirming ? (
<button
type="button"
onClick={() => setConfirming(true)}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white duration-200 hover:bg-primary-600"
>
Redeem…
</button>
) : (
<div className="rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-500/10 dark:text-amber-400">
<p>
<strong>This choice is final</strong> — {selected?.label}{" "}
can't be swapped for another template later.
</p>
<div className="mt-3 flex gap-2">
<button type="submit" disabled={pending} /* … */>
{pending ? "Redeeming…" : "Confirm redemption"}
</button>
<button type="button" onClick={() => setConfirming(false)} /* … */>
Cancel
</button>
</div>
</div>
)}
Three details in that block are doing the work, and all three generalise to a cart.
The warning names the item. {selected?.label} is interpolated into the sentence, so the confirm step reads "ASoc Admin (Next.js) can't be swapped" rather than "this action cannot be undone". A generic warning is noise a shopper clicks through; a specific one is a last line item they can check.
Changing the selection resets the confirm. The onChange handler calls setConfirming(false) alongside setSelectedValue. Without that line, a shopper could open the confirm for item A, change the dropdown to item B, and commit against a warning that still described A. That is the cart-page version of a stale total — the screen agreed with itself a moment ago and no longer does.
The pending state is a label, not a spinner. {pending ? "Redeeming…" : "Confirm redemption"} plus disabled={pending} means the button cannot be double-fired and says why. A cart's checkout button needs exactly this; a double-submitted order is the most expensive bug in commerce.
What the component does not do is trust itself. The options list is pre-filtered to eligible choices, and the comment above it says why that is not enough:
/**
* `options` is pre-filtered by the caller to only eligible choices
* (spec §9: "picker only lists eligible options") — the server action
* re-validates regardless, since a forced request can bypass this UI
* entirely.
*/
Cart design and cart security meet here. A cart page is a form, every quantity and price in it arrives from the client, and the server has to recompute the total from its own catalog. Rendering a correct total is a UX feature; trusting the one that comes back is how you get a $0.01 order.
On a phone, region order is the design
A cart page at desktop width is two columns: line items left, order summary and CTA right, both visible at once. On a phone those columns stack, and the naive stack puts the entire line-item list above the summary — so the total and the checkout button land below the fold, under however many items the shopper added.
This is a source-order problem, and the fix is not a second copy of the button. src/components/organisms/TemplateDetail.tsx solves the same problem on the product page by making the sidebar column stop generating a box below md, so its blocks become grid items the parent can order individually:
<div className="mx-auto mt-12 grid max-w-[1060px] gap-10 md:grid-cols-[1.4fr_1fr]">
<div className="order-2 md:order-none">{/* prose */}</div>
<div className="contents md:block md:space-y-8">
<div className="order-1 md:order-none">{/* editions + CTA */}</div>
<div className="order-3 space-y-3 md:order-none">{/* the rest */}</div>
</div>
</div>
display: contents is the whole trick. Below md the wrapper renders no box, its three children become direct grid items, and order-1/order-2/order-3 interleave the sidebar's first block between the prose blocks. At md it is block again and the original two-column layout returns, with one instance of every control.
For a cart, that means the order summary and checkout button can sit directly under the first line item on a phone while staying in the right-hand column on desktop — without shipping two buttons and without duplicating state. The comment in that file flags the constraint that makes it safe: "the prose has no focusable content, so the visual reshuffle never disagrees with the tab order." Reordering visually while leaving the DOM order alone is exactly how you break keyboard navigation, so the technique is only safe when the blocks you hop over hold nothing focusable.
The hover trap, learned the expensive way
A defect this codebase shipped and fixed, because it is the single most common cart-adjacent mistake: a control that only exists on hover.
The template cards carry a blurred overlay with a Preview button that appears on group-hover/media. It looks good and it was briefly the only way to open a preview. The code now says what it is:
<div className="invisible absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-[rgba(152,162,179,0.32)] opacity-0 backdrop-blur-[15px] duration-200 group-hover/media:visible group-hover/media:opacity-100">
<button aria-hidden="true" tabIndex={-1} type="button" onClick={openPreview}>
Preview
</button>
</div>
aria-hidden="true" and tabIndex={-1} are there because this button is a pointer shortcut and nothing else. A touch screen has no hover, and an invisible child cannot take focus, so for a phone visitor and a keyboard user this control does not exist. The real control is an always-visible button below the card; the overlay just puts a duplicate where the mouse already is, and it is removed from the accessibility tree so screen-reader users do not hear the same action twice.
Translate that to a cart page and the list of things never to put behind hover is short and important: remove-item, quantity steppers, edit-variant, and the checkout button itself. If a control changes the order, it is visible at every width or it does not exist.
The measured consequence of taking this seriously: accessibility is 100 on every page of this site, on both desktop and mobile, and CLS is 0 on every page measured — numbers and method in docs/superpowers/LIGHTHOUSE.md. CLS matters for carts specifically, because a total that reflows after the shopper has aimed at the checkout button is how a mis-tap becomes a lost order.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Checkout button below the fold on phones | Line items stack above the summary in source order | Reorder with contents + order-*, not a duplicate button |
| Shoppers abandon at the cart more than at checkout | Shipping or tax first appears on the next screen | Show the full breakdown, or an explicit "calculated at checkout" row |
| Double orders from one shopper | CTA stays enabled while the request is in flight | disabled={pending} and a pending label on the button |
| Confirm dialog describes the wrong item | Selection changed without resetting the confirm state | Reset the confirm flag in the selection handler |
| Remove-item unreachable on mobile | The control lives in a hover overlay | Make it always visible; mark any hover duplicate aria-hidden |
| Totals correct on screen, wrong in the order | The server trusted client-sent prices | Recompute every total server-side from your own catalog |
| Empty cart is a dead end | No path back into the catalog | Link the category the shopper came from, plus one bestseller row |
Frequently asked questions
Should the cart be a page or a slide-out drawer? Both, usually: a drawer for the add-to-cart confirmation, a page for reviewing a multi-item order. Pick one if you can only build one, and pick by whether the order needs reviewing. One fixed-price item does not.
Where do upsells belong on a cart page? Below the primary CTA, never between the line items and the total. An upsell that pushes the checkout button down is buying a chance at a bigger order by risking the order you already have.
Does the cart page need to be server-rendered? The shell should be; the cart contents are per-shopper and belong on the client or in a per-request render. Where cart state should live in Next.js 16 covers the split, and adding items with plain HTML and JavaScript covers the no-framework version.
How many trust signals are too many? Three: refund terms, payment security, and support. A wall of badges reads as compensation for something.
Templates in this post
ASoc Cover (an insurance marketing site with instant quotes), ASoc Echo (an AI chatbot SaaS landing page) and ASoc Edge (an applied-AI agency site) each ship a Next.js edition, so the commit-step and region-order patterns above have a finished layout to land on.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
