Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. React Checkout Page: Cart, Steps, Coupons and a Pay Bar
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. React Checkout Page: Cart, Steps, Coupons and a Pay Bar
Build it: screens and apps
Build it: screens and apps

React Checkout Page: Cart, Steps, Coupons and a Pay Bar

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 12 min read

On this page

0%
  1. What does a React checkout page need?
  2. How do I wire the checkout page into Next.js?
  3. How should the React cart page handle quantities and coupons?
  4. How do I use React checkout steps without the full page?
  5. Which form details reduce checkout drop-off?
  6. How do I keep the pay button reachable in a mobile checkout UI?
  7. Do I need a cart drawer as well as a cart page?
  8. How do I try the checkout page before wiring it up?
  9. FAQ
    1. How many steps should a checkout have?
    2. Should a React checkout page force account creation?
    3. Where should coupon codes be validated?
    4. How do I keep the pay button visible on a mobile checkout?
    5. Can the browser autofill a custom React checkout form?

TL;DR

A React checkout page needs three stages: a cart that edits quantities and coupons with the total always visible, four checkout steps (address, delivery, payment, review) that validate one step at a time and keep data on Back, and a confirmation that takes focus. On phones, keep the total and the main button in a sticky bar above the home indicator and hide it while the keyboard is open. The Wingo UI Checkout Page block does all of this; you wire up the cart API, the coupon check and the order call.

Published Oct 16, 2026

Most tutorials for a React checkout page stop at a form with a Pay button. The real work sits around that form: a cart that edits quantities without losing the total, coupon codes that stop applying when the cart shrinks, a step that fails without wiping what the buyer typed, and a pay button that stays under the thumb on a phone. This tutorial builds the whole flow, from cart to confirmation, with the Checkout Page block from Wingo UI, the library we build, and shows the smaller parts you can use on their own.

What does a React checkout page need?

It needs three stages and a short list of details that decide whether people finish. The stages:

  1. Cart. Lines with quantity steppers, remove with undo, a free delivery bar, a coupon field and a summary whose total updates on every change.
  2. Checkout steps. Address, delivery, payment and review. Each step validates on Continue, Back keeps everything, and the review step places the order and shows a refusal in place.
  3. Confirmation. The order number, the email it went to, Track order and Continue shopping, with focus moved to the heading so a screen reader announces it.

The details come from research on why people leave. Baymard Institute's cart abandonment page (opens in a new tab) (updated September 22, 2025) lists the top reasons shoppers in its survey gave for abandoning a checkout: extra costs too high (40%), slow delivery (20%), not trusting the site with card details (19%), required account creation (18%) and a checkout that was too long or complicated (17%). Another 12% could not see or calculate the total up front. Four of those are UI problems: show the total and delivery cost early, allow guest checkout, keep the form short, and make the payment step look trustworthy.

Baymard's checkout benchmark (opens in a new tab) (published June 26, 2024) puts the average checkout at 5.1 steps and 11.3 form fields, and says most sites need only 8 fields. Fields cost more than steps, so the checkout below asks for one name field instead of first and last, and hides the company fields behind a switch.

Install the block. The CLI pulls in the cart, steps, coupon, sticky bar and order tracker it composes:

bash
npx wingo-ui@latest add checkout-page
Change a quantity and watch the free delivery bar and the total follow, apply FALL15 in the coupon field, then press Continue to delivery and go through the four steps. On a narrow screen, scroll past the summary and the sticky bar with the total slides up
$ npx wingo-ui@latest add checkout-page
ProCheckout Page docs

How do I wire the checkout page into Next.js?

Render CheckoutPage in a client component and hand it four callbacks: one that saves the cart, one that checks a coupon on the server, one that places the order and one that opens the order. The block owns the stages, the step state, focus and the transitions. It works as a checkout page template in one import: every string sits in labels and every visual choice is a prop.

One pitfall first. The checkout steps inside the block default to Romanian rules (6 digit postal codes, the CUI tax ID check, phones starting at +40), and the block has no country prop of its own. A US ZIP code such as 94103 then fails the address step with "The postal code has 6 digits". With a non-Romanian locale the block already swaps the county picker for US states, but the country rules stay. Set the country once through UIProvider defaults, which the steps read for any prop the block leaves out, and rename the company fields through labels.checkout:

tsx
// app/(shop)/cart/checkout.tsx
"use client";
import { useRouter } from "next/navigation";
import { CheckoutPage, type CheckoutPageProps } from "@/components/blocks/checkout-page";
import type { CouponResult } from "@/components/ui/coupon-input";
import { UIProvider } from "@/lib/ui-config";
// US postal codes and phones in the address step; tax IDs are no longer checked as Romanian CUIs
const US_CHECKOUT = { "checkout-steps": { country: "US" } };
// the company fields keep their Romanian names and placeholders unless you rename them
const US_COMPANY_LABELS = {
cui: "Tax ID (EIN)",
cuiPlaceholder: "12-3456789",
regCom: "State registration no. (optional)",
regComPlaceholder: "",
};
type ShopData = Pick<
CheckoutPageProps,
"defaultItems" | "coupons" | "savedAddresses" | "deliveryMethods" | "lockers" | "paymentMethods" | "customerEmail" | "guest"
>;
export function Checkout(shop: ShopData) {
const router = useRouter();
return (
<UIProvider defaults={US_CHECKOUT}>
<CheckoutPage
{...shop}
recommended={[]}
currency="USD"
locale="en-US"
freeShippingThreshold={250}
ctaTone="brand"
labels={{ checkout: US_COMPANY_LABELS }}
onItemsChange={(items) =>
fetch("/api/cart", { method: "PUT", body: JSON.stringify(items) })
}
onApplyCoupon={async (code): Promise<CouponResult> => {
const response = await fetch("/api/coupons/check", {
method: "POST",
body: JSON.stringify({ code }),
});
if (!response.ok) return { ok: false, error: "We don't recognize that code" };
return (await response.json()) as CouponResult;
}}
onPlaceOrder={async (value, totals) => {
const response = await fetch("/api/orders", {
method: "POST",
body: JSON.stringify({ value, total: totals.total }),
});
// the message shows on the review step, above Place order
if (!response.ok) throw new Error(await response.text());
return (await response.json()) as { orderNumber: string };
}}
onTrackOrder={(orderNumber) => router.push(`/account/orders/${orderNumber}`)}
onContinueShopping={() => router.push("/")}
/>
</UIProvider>
);
}

The page itself stays a server component that loads the data. getCart and getShop stand for your own data layer:

tsx
// app/(shop)/cart/page.tsx
import { getCart, getShop } from "@/lib/shop";
import { Checkout } from "./checkout";
export default async function CartPage() {
const [cart, shop] = await Promise.all([getCart(), getShop()]);
return (
<Checkout
defaultItems={cart.items}
coupons={shop.coupons}
savedAddresses={cart.customer?.addresses ?? []}
deliveryMethods={shop.deliveryMethods}
lockers={shop.lockers}
paymentMethods={shop.paymentMethods}
customerEmail={cart.customer?.email}
guest={!cart.customer}
/>
);
}

Every data prop you leave out falls back to the block's sample shop (including the sample buyer's email when customerEmail is undefined), which is why the wrapper passes the cart, the coupons, the addresses, the delivery methods with their lockers, the payment methods and an empty recommended rail. Use items with onItemsChange instead of defaultItems when another part of the page, such as a header badge, also reads the cart. Each line is a CartItem: id, title, price, quantity, and optionally compareAtPrice, maxQuantity, taxRate, image, variant and href. guest drops the saved addresses and the prefilled email and adds a Create an account hint to the confirmation, so nobody is asked for an account up front. Save for later confirms itself with a toast, so mount <Toaster /> from @/components/ui/toast once in the shop layout.

Two rules for the server side. First, recompute the total from your own prices and never trust totals.total from the browser. The block sums the cart with computeCartTotals from @/lib/commerce, a plain module without "use client", so a route handler can run the same math and reject an order whose total moved:

ts
// app/api/orders/route.ts
import { computeCartTotals } from "@/lib/commerce";
import type { CheckoutValue } from "@/components/ui/checkout-steps";
import { getCart, getShop } from "@/lib/shop";
export async function POST(request: Request) {
const { value, total } = (await request.json()) as { value: CheckoutValue; total: number };
const [cart, shop] = await Promise.all([getCart(), getShop()]);
const method = shop.deliveryMethods.find((m) => m.id === value.deliveryMethod);
const payment = shop.paymentMethods.find((m) => m.id === value.paymentMethod);
// applied codes, in the order the buyer added them, looked up in your own catalogue
const coupons = (value.coupons ?? []).flatMap((applied) => shop.coupons.filter((c) => c.code === applied.code));
const totals = computeCartTotals(cart.items, {
coupons,
shipping: method?.price ?? null,
// the steps' rule: a method's freeFrom wins, pickup never takes the threshold
freeShippingThreshold: method?.freeFrom ?? (method?.type === "pickup" ? 0 : 250),
});
const expected = totals.total + (payment?.fee ?? 0);
if (Math.abs(expected - total) > 0.005) {
return new Response("Prices changed while you were checking out. Review the total and place the order again.", { status: 409 });
}
// charge `expected`, store the order, then answer with its number
return Response.json({ orderNumber: "1043" });
}

Second, keep the response honest: a declined card should be a non-2xx response with a message the buyer can act on, because the block shows that message above Place order and keeps the review step open.

How should the React cart page handle quantities and coupons?

Every change to the cart moves the total, and nothing the buyer does is lost without a way back. The cart stage follows four rules:

  • Every change moves the summary. Steppers, removals and coupons update the subtotal, the savings badge, the delivery estimate and the total in place, so the buyer never wonders what they will pay.
  • Removal is undoable. The × (or a swipe left on touch) folds the line into a row with Undo and a five second countdown. Save for later moves a line to a list below the cart instead of deleting it.
  • Free delivery is visible. A bar shows how much more is needed and turns green at the threshold, which answers the "extra costs" question before checkout starts.
  • Coupons recheck. A code with a minimum order is rechecked when a quantity changes. If it stops applying, the block removes it and says why, instead of showing a discount the server will refuse.

The coupon field is the Coupon Input, and it works on its own in any cart. Give it a local catalogue for instant checks, or onApply for a server check that resolves { ok: true, coupon } or { ok: false, error }:

tsx
"use client";
import { useState } from "react";
import { CouponInput, type AppliedCoupon } from "@/components/ui/coupon-input";
import type { Coupon } from "@/lib/commerce";
const COUPONS: Coupon[] = [
{ code: "FALL15", type: "percent", value: 15, maxDiscount: 150, label: "Fall sale" },
{ code: "FREESHIP", type: "shipping", value: 0, label: "Free delivery" },
{ code: "SAVE50", type: "fixed", value: 50, minSubtotal: 300 },
];
export function CartCoupon({ subtotal, shipping }: { subtotal: number; shipping: number }) {
const [applied, setApplied] = useState<AppliedCoupon[]>([]);
return (
<CouponInput
value={applied}
onValueChange={setApplied}
coupons={COUPONS}
subtotal={subtotal}
shipping={shipping}
locale="en-US"
/>
);
}

Pass the items' subtotal and the current shipping cost on every render, in the same currency units as the coupon values (dollars, not cents). They are what a catalogue code is checked against, and when they change, applied codes recount what they save. A code that no longer applies turns amber with a note under it, so the buyer sees why the discount went before the server refuses it.

Open "Have a promo code?", type save50 and press Enter: it is upper-cased, checked and lands as a chip. Remove it and try big200 to see how much more the cart needs
$ npx wingo-ui@latest add coupon-input
ProCoupon input docs

Enter inside the coupon field never submits the checkout form around it, which is a common bug when the code field sits inside a <form>. Escape clears the typed code, and a second Escape closes the field and returns focus to the "Have a promo code?" button.

How do I use React checkout steps without the full page?

Use Checkout Steps directly when the cart lives somewhere else, such as a drawer, or when you sell something without delivery. Most stepper examples stop at three forms behind Next buttons. This is the same four step flow the block uses, with validation, saved addresses, lockers and payment fees built in, beside a summary that is a sticky column on wide screens and a Show summary bar on narrow ones. It defaults to Romanian rules (country="RO", RON), so set the country, currency and locale for a US shop:

tsx
"use client";
import { CheckoutSteps, type DeliveryMethod, type PaymentMethod } from "@/components/ui/checkout-steps";
import type { CartItem } from "@/components/ui/cart-drawer";
import type { Coupon } from "@/lib/commerce";
const STATES = [
{ value: "CA", label: "California" },
{ value: "NY", label: "New York" },
{ value: "TX", label: "Texas" },
];
const DELIVERY: DeliveryMethod[] = [
{ id: "ups", label: "UPS Ground", price: 19.99, eta: "3-5 business days", type: "courier" },
{ id: "pickup", label: "Pick up in New York", price: 0, type: "pickup" },
];
// mount your payment provider's card element here, so card data never touches your server
function CardFields() {
return <div id="card-fields" className="min-h-24" />;
}
const PAYMENT: PaymentMethod[] = [
{ id: "card", label: "Card", details: <CardFields /> },
{ id: "transfer", label: "Bank transfer" },
];
export function Checkout({ items, coupons }: { items: CartItem[]; coupons: Coupon[] }) {
return (
<CheckoutSteps
items={items}
couponProps={{ coupons }}
country="US"
currency="USD"
locale="en-US"
regions={STATES}
deliveryMethods={DELIVERY}
paymentMethods={PAYMENT}
invoice={false}
freeShippingThreshold={250}
tone="brand"
labels={{
county: "State",
countyPlaceholder: "Choose a state",
placeOrder: (total) => `Pay ${total}`,
}}
onPlaceOrder={(value, totals) =>
fetch("/api/orders", { method: "POST", body: JSON.stringify({ value, total: totals.total }) }).then(
async (response) => {
if (!response.ok) throw new Error(await response.text());
},
)
}
successContent={<p>Thanks, your order is placed. We sent the receipt to your email.</p>}
/>
);
}

For a digital product, pass steps={["payment", "review"]} and leave out deliveryMethods. Watch one catch: the email field lives in the address step, so without that step nothing collects it. Take the email from the signed-in account, or ask for it before the checkout opens. A payment method with fee adds a row to the summary and the total, and one with minTotal stays disabled with an Available from note until the cart reaches it. The field checks are exported as validateCheckoutStep(step, value, options), which returns errors keyed like "address.phone" or "invoice.cui". It lives in a "use client" file, so call it from client code (a custom flow, a unit test) and validate the submitted value on the server with your own schema.

The mechanics are the same as any wizard: one value object, validation per step, focus on the first invalid field, and slides that follow direction. If you want that pattern for a form that is not a checkout, the React multi step form tutorial builds it with react-hook-form and zod.

Which form details reduce checkout drop-off?

Autofill, fewer fields, errors that keep what was typed, a useful Enter key, an early total and guest checkout. The steps build in each one:

  • Let the browser fill the form. Every address field carries its autocomplete value (email, name, tel, address-line1, address-line2, address-level1, address-level2, postal-code, organization), so one tap on a saved address fills the step. Card fields should come from your payment provider's hosted element, so card data never touches your server.
  • Ask for less. One name field, the company fields only after the Company switch, and notes as optional.
  • Fail without losing anything. Continue marks the wrong fields, shakes the button and focuses the first one. Back never validates, and a declined payment keeps the review step and every choice intact.
  • Make Enter useful. On a phone the keyboard's action key moves to the next field and continues from the last one, so the buyer does not hunt for a button behind the keyboard.
  • Show the total early. The summary, with delivery and any payment fee, is visible on every step, and the Place order button carries the final amount.
  • Do not force an account. Guest checkout first, the account offer on the confirmation.

How do I keep the pay button reachable in a mobile checkout UI?

On a phone the summary's Continue button scrolls out of view fast, and, as Stripe's mobile checkout guide (opens in a new tab) (updated July 8, 2025) points out, the on-screen keyboard takes nearly half the screen once a field has focus. A good mobile checkout UI handles both: a bar with the total and the action appears while the page's own button is off screen, sits above the home indicator, and steps aside while the keyboard is open.

The block does this with the Sticky CTA Bar in the cart stage, below 1024px. Inside the checkout steps, each step on a phone is a full-height page with its own footer holding the total, Back and Continue, hidden while the keyboard is up. To use the bar on your own product or cart page, point it at your in-page button:

tsx
"use client";
import { useRef } from "react";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StickyCtaBar } from "@/components/ui/sticky-cta-bar";
export function CartFooter({ total, onContinue }: { total: number; onContinue: () => Promise<void> }) {
const continueRef = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={continueRef} tone="brand" onClick={onContinue}>
Continue to delivery
</Button>
<StickyCtaBar
price={total}
currency="USD"
locale="en-US"
caption="Tax included · Free delivery"
actionLabel="Continue"
actionIcon={<ArrowRight />}
tone="brand"
onAction={onContinue}
showOn="anchor"
anchorRef={continueRef}
hideFrom="lg"
/>
</>
);
}
Scroll the phone frame: the bar glides up once the page's own button leaves the screen and drops away when it comes back. Tap the action to see its spinner keep the button width
$ npx wingo-ui@latest add sticky-cta-bar
ProSticky CTA Bar docs

keyboard="hide" is the default, so the bar never sits on top of a focused coupon field. safeArea pads the home indicator, and the bar writes its height to --cta-bar-offset so a floating button can sit above it. The reasons behind both are in our posts on Tailwind safe area insets and position fixed under the iOS Safari keyboard.

Do I need a cart drawer as well as a cart page?

Yes, if buyers add to the cart from product pages: they want to check the cart from the header without leaving the page. The Cart Drawer is a side panel on desktop and a bottom sheet on phones, with the same undo, free delivery bar, coupon field and summary as the cart stage. Point its button at the checkout with checkoutHref="/cart", and as long as both read the same cart API, the buyer lands in the block's cart stage with the same lines.

How do I try the checkout page before wiring it up?

Start from the Checkout Page docs: the playground lets you open it on any stage or step, switch to a guest buyer, change the free delivery threshold and the tone, and try the declined card ending 0002. The block and the commerce components in this post are part of Wingo UI Pro, which costs $8 a month, $80 a year or $150 for lifetime access as of October 2026 (pricing). For the other screens a product needs, read the Next.js admin dashboard tutorial and the rest of the build tutorials.

Components in this post

  • Checkout Page

    A React checkout page from the cart to Order placed: an editable cart, four checkout steps and the order confirmation.

    Pro
  • Checkout steps

    A React multi-step checkout: address and invoice, delivery with lockers, payment and a review that places the order, beside a summary.

    Pro
  • Cart drawer

    A React shopping cart drawer: a side panel on desktop, a bottom sheet on phones, with undo, free delivery progress, a promo code and totals.

    Pro
  • Coupon input

    A React coupon code input for carts and checkouts: a link that opens it, Apply inside the box, async checks and applied codes as chips.

    Pro
  • Sticky CTA Bar

    A React sticky CTA bar for product, listing and checkout pages on phones: the price on one side, the main action on the other.

    Pro

FAQ

How many steps should a checkout have?

Fewer fields matter more than fewer steps. Baymard's 2024 benchmark found an average of 5.1 steps and 11.3 form fields, and says most sites need only 8 fields, so cut fields first and split what remains into address, delivery, payment and review.

Should a React checkout page force account creation?

No. In Baymard's cart abandonment survey (page updated September 2025), 18% of shoppers who abandoned a checkout did so because the site wanted them to create an account. Offer guest checkout and suggest an account on the confirmation page, after the order is placed.

Where should coupon codes be validated?

Check them twice: on the client for instant feedback when the buyer applies a code, and on the server when the order is placed. Recheck applied codes whenever the cart changes, because a minimum order code can stop applying after a quantity drops.

How do I keep the pay button visible on a mobile checkout?

Render a fixed bar at the bottom with the total and the main action, padded by the safe area inset so it clears the home indicator. Show it only while the page's own button is off screen, and hide it while the on-screen keyboard is open so it never covers the focused field.

Can the browser autofill a custom React checkout form?

Yes, as long as every field is a real input with the right autocomplete value, such as name, email, tel, address-line1, address-level2, postal-code and the cc- values for cards. Fields built from divs, or inputs with autocomplete turned off, break it.

  • E-commerce
  • Checkout
  • Forms
  • Mobile UI
  • Next.js

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

React Floating Action Button: Speed Dial and Safe Areas

A React floating action button for the thumb zone: a 2 to 6 action speed dial, a label that folds on scroll, and a spot above tab bars and the home indicator.

SRSerban Rusu·Oct 16, 2026·9 min read
Component guides

React Stepper Component: States, Variants and Phones

A React stepper component guide for checkout, onboarding and KYC: circles, bars or dots, what each step state means, and how a stepper collapses on a phone.

SRSerban Rusu·Oct 13, 2026·8 min read
Mobile UI in React

Mobile-First React Components: 9 Rules With Code

Mobile-first React components, rule by rule: bottom sheets instead of popovers, 44px touch targets, safe areas, dvh, keyboard-aware bars and tables as cards.

SRSerban Rusu·Oct 9, 2026·21 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI