Topic 25.6
Design an E-commerce Product and Checkout Flow
In one line
A shop's frontend must be found by search engines, load fast on phones, keep filters shareable in the URL, keep one cart across tabs and devices, make checkout forms easy and correct, keep card data away from your own code, and measure every step of the funnel.
Think of it like this
A big supermarket. The aisle signs are the URL: "Aisle 4, shoes, under ₹5000" lets a friend find exactly what you saw. Your trolley follows you around the store, whichever door you came through (the cart across tabs). At the till, the cashier doesn't touch your card; you tap it on the bank's machine (the payment provider's secure field). And the manager counts how many people pick things up, reach the till and actually pay (analytics).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- PLP / PDP
- Product listing page (a grid of products with filters) and product detail page (one product).
- URL state
- State stored in the address bar's query string, so it can be shared, refreshed and undone with Back.
- Canonical URL
- The one address search engines should treat as the real page when several URLs show similar content.
- Structured data
- Machine-readable facts (JSON-LD) about a page, such as a product's price and stock, for search engines.
- BroadcastChannel
- A browser API that sends messages between tabs and windows of the same site.
- PCI DSS
- The card industry's security rules for anyone handling card numbers.
- Hosted fields
- Card inputs served in an iframe by the payment provider, so your page never sees the card number.
- Idempotency key
- A unique id sent with a request so the server performs it once even if it arrives twice.
- Funnel
- The steps from browsing to paying, measured to see where shoppers drop out.
Step by step
01Requirements and the questions that matter
Ask: how many products (thousands or millions)? Which countries, currencies and languages? Guest checkout? One payment provider? Personalised recommendations? Then numbers: 24 products per page, LCP < 2.5 s on 4G, filters update results within 300 ms, checkout in under 2 minutes for a returning user.
02Architecture and rendering per route
Catalogue pages are server-rendered from cacheable data and pushed through a CDN. The cart and checkout are dynamic and personal. Payment happens inside the provider's iframe, and the provider tells the server about the result through a webhook (a server-to-server callback).
03Component tree
The listing reads its filters from the URL; the cart badge, mini cart and checkout read one cart store.
04Data model
Money is an integer in the smallest unit plus a currency, never a float. The cart stores what the user chose; prices in it are a cache of the server's answer.
type Money = { amount: number; currency: "INR" | "USD" | "EUR" }; // amount in paise / cents
type Product = { id: string; slug: string; name: string; images: Image[]; variants: Variant[]; rating?: number };
type Variant = { sku: string; size?: string; color?: string; price: Money; inStock: boolean };
type Filters = { cat: string[]; max?: number; sort: "relevance" | "price-asc" | "price-desc"; page: number };
type CartLine = { sku: string; qty: number };
type Cart = { id: string; lines: CartLine[]; version: number; // version detects concurrent edits
totals?: { subtotal: Money; tax: Money; shipping: Money; total: Money } }; // from the server only05Interface (API)
Listing queries mirror the URL. Cart writes carry a version so two tabs or devices can't silently overwrite each other. Placing the order is idempotent.
GET /api/products?cat=shoes&cat=socks&max=15000&sort=price-asc&page=2&limit=24
-> { items: ProductSummary[], total: 213, facets: { cat: [{ value: "shoes", count: 120 }] } }
GET /api/products/trail-runner-2 -> Product
PUT /api/cart/lines/SKU-123 { qty: 2, version: 7 } -> Cart (409 if version is stale)
POST /api/cart/merge { guestLines: CartLine[] } -> Cart (on sign-in)
POST /api/orders Idempotency-Key: 6f1c... { cartId, addressId, deliveryOption }
-> { orderId, paymentClientSecret } # used by the provider's iframe to confirm payment06Deep dive: filter changes
A checkbox click writes the URL; the URL change drives the data request; the list re-renders. Nothing else holds the filters, so they can't disagree.
07Deep dive: checkout security and analytics
Your code never handles the card number: the provider's iframe does, and your server confirms the payment through the provider's API or webhook before marking the order paid. The checkout page loads no ad or chat scripts and runs under a strict Content Security Policy. Analytics events go through one track(event, data) function that checks consent and batches events.
Try it yourself
- 1
Untick a category
In the filters example, add a step that unticks
shoesafter the sort. Predict the URL: doessort=price-ascsurvive? Does the order of thecatvalues matter for caching? - 2
Replace instead of push
Pass
{ replace: true }as the second argument ofsetParams. Predict what Back does now, then run it. When would you want that (think of a price slider)? - 3
Add a third tab
In the cart example, create
tabCand render a third panel. Predict how many "got an update" lines one click now prints.
Code & diagrams
The URL is the only place filters live. `page=3` is dropped on the first change, two categories become `cat=jackets&cat=shoes`, and Back undoes the last change exactly like a browser would.
Preview
Console
Nothing logged yet.Expected console output
/shop?cat=jackets -> Rain jacket $150, Down jacket $220
/shop?cat=jackets&cat=shoes -> Road shoe $120, Trail shoe $90, Rain jacket $150, Down jacket $220
/shop?cat=jackets&cat=shoes&sort=price-asc -> Trail shoe $90, Road shoe $120, Rain jacket $150, Down jacket $220
/shop?cat=jackets&cat=shoes -> Road shoe $120, Trail shoe $90, Rain jacket $150, Down jacket $220Two stores stand in for two tabs. Each change is saved and broadcast; the other store replaces its copy and re-renders through `useSyncExternalStore`. Real apps also listen for the `storage` event, and signed-in carts are saved on the server.
Preview
Console
Nothing logged yet.Expected console output
Tab B got an update: {"SKU-1":1}
Tab A got an update: {"SKU-1":1,"SKU-2":1}
Cart: 2 | Cart: 2function AddressStep() {
const [state, formAction, pending] = useActionState(saveAddress, { errors: {} }); // server validates
return (
<form action={formAction} noValidate aria-describedby="err-summary">
{Object.keys(state.errors).length > 0 && (
<div id="err-summary" role="alert" tabIndex={-1}>Please fix {Object.keys(state.errors).length} fields</div>
)}
<label htmlFor="name">Full name</label>
<input id="name" name="name" autoComplete="shipping name" required aria-invalid={!!state.errors.name} />
<label htmlFor="street">Address</label>
<input id="street" name="street" autoComplete="shipping street-address" required />
<label htmlFor="pin">PIN code</label>
<input id="pin" name="pin" autoComplete="shipping postal-code" inputMode="numeric" />
<label htmlFor="tel">Phone</label>
<input id="tel" name="tel" type="tel" autoComplete="tel" />
<button disabled={pending}>Continue to payment</button>
</form>
);
}
function PaymentStep({ clientSecret }: { clientSecret: string }) {
// The provider's component renders an iframe: card numbers never enter this page's JavaScript.
return (
<ProviderElements clientSecret={clientSecret}>
<ProviderPaymentForm onPaid={() => router.push("/order/confirmed")} />
</ProviderElements>
);
}
// The server marks the order paid only after the provider confirms it (API call or signed webhook).Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to reset the page
In the filters example, delete next.delete("page"); from update.
Myth vs fact
Myth
Keep filters in component state and sync them to the URL in an effect.
Fact
That makes two sources of truth that can disagree and adds an extra render. Read filters from the URL and write them to the URL; the URL is the state.
Myth
Calculating the total in the browser is fine because the user sees it.
Fact
The browser's total is a display. Prices, discounts, tax and stock are recalculated on the server when the order is placed, or anyone could edit the request and pay less.
Myth
Using HTTPS is enough to handle card numbers safely.
Fact
HTTPS protects the trip, not your page. A compromised script on your checkout could read the numbers. Hosted fields keep card data in the provider's iframe, out of your page's reach, and shrink your PCI DSS scope.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Facet counts ("Shoes (120)") must reflect the other active filters, which is a search-engine query, not client filtering. The client shows them, disables zero-count options, and doesn't hide selected ones.
- ▸
For search engines, render filter links as real
<a href>with the query string (not only buttons), so crawlers can discover important category pages, and mark low-value combinationsnoindexwith a canonical to the main category. - ▸
PCI DSS 4.0 asks merchants to keep an inventory of the scripts on payment pages and detect unauthorised changes to them. Fewer scripts on checkout means less to inventory and less risk (Phase 21.6).
- ▸
Use the ecommerce event names your analytics tool expects (for example GA4's
view_item_list,add_to_cart,begin_checkout,purchase) and send the purchase from the server too, because ad blockers and closed tabs drop browser events.
Remember this
- 1
Requirements. Functional: a product listing page (PLP) with filters, sort and pagination; a product detail page (PDP) with a gallery, variants (size, colour), stock and price; a cart; a checkout (address, delivery, payment, review); an order confirmation. Non-functional: pages are indexed by search engines (most shoppers arrive from search), LCP under 2.5 s on 4G phones (slow pages lose sales), CLS under 0.1, prices and stock are never wrong at payment, card data is handled under PCI DSS (the card industry's security standard), forms are accessible (WCAG 2.2 AA), the site works in several currencies and languages, and every funnel step is measured. Clarify: catalogue size, guest checkout, regions and currencies, one payment provider or many.
- 2
Filters in the URL. Category, price range, sort and page are URL state (Phase 13, 15.1):
/shoes?cat=trail&max=150&sort=price-asc&page=2. A shared link shows the same results, refresh keeps them, Back undoes the last filter, and the server can render that exact list for search engines. Rules: one source of truth (read filters fromuseSearchParams, don't copy them intouseState), **resetpagewhen a filter changes, keep a stable key order** so the same filters make the same URL (good for caching), and usereplacefor rapid changes (a slider) but a new history entry for deliberate ones (a checkbox). - 3
SSR for SEO. Product listing and product pages are server-rendered (or statically generated and revalidated, ISR) so crawlers and link previews get full HTML with the title, price and images, and so the LCP image starts downloading from the first HTML byte (Phase 20.1). Add a
<link rel="canonical">so the many filter combinations don't compete as duplicate pages (most combinations getnoindex), structured data (ProductJSON-LD with price and availability) for rich search results, and React 19's<title>/<meta>in components (Phase 19). Personal parts (cart count, recommendations) load on the client or stream in after the main content. - 4
Image galleries. The first product image is usually the LCP element: give it
fetchpriority="high", serve it withsrcset/sizesin AVIF or WebP, and reserve its box with width/height oraspect-ratio. Other images lazy-load; the next one preloads when the user starts swiping. Thumbnails are buttons witharia-label="Show image 2 of 5"andaria-currenton the active one; arrow keys change the image; zoom opens a dialog that traps and returns focus (Phase 18.2); motion respectsprefers-reduced-motion. Changing the colour variant swaps the gallery without a page reload and updates the URL (?color=black) so it can be shared. - 5
Cart across tabs and devices. For a guest, the cart lives in the browser:
localStorage(or IndexedDB) plus a way to tell other tabs it changed. Thestorageevent fires in other tabs when one tab writeslocalStorage, andBroadcastChannelsends messages between tabs of the same site; a store read withuseSyncExternalStore(Phase 10.3) re-renders when either arrives. For a signed-in user the server cart is the source of truth (it follows them to their phone), and on sign-in the guest cart is merged into it. The client's prices and totals are only a display: the server recalculates price, tax, discounts and stock at checkout. - 6
Checkout and payment boundaries. Checkout forms use the right
autocompletetokens (shipping street-address,postal-code,email,tel) so browsers fill them in seconds,inputModefor number keyboards, labels on every field, validation on blur plus an error summary that receives focus on submit, and server validation as the authority (Phase 5, Phase 19 form actions). Card details go into the payment provider's hosted fields or page (iframes from Stripe, Adyen, Razorpay and similar): the numbers never touch your JavaScript or servers, which keeps you out of most PCI scope and protects you from a compromised script on your page reading them. Place-order requests carry an idempotency key so a double click or retry can't charge twice. Keep third-party scripts off the checkout page and lock it down with a strict CSP (Phase 21.6). Analytics track the funnel (list viewed, product viewed, added to cart, checkout started, purchase) with a small event API, sent withsendBeacon, respecting consent, and the purchase is confirmed from the server, not only the browser.
Explain it without notes
Why should filters live in the URL, and how do you implement it in React?
How would you render product pages for SEO and speed?
How do you keep the cart consistent across tabs and devices?
What are the security boundaries in a checkout?
What analytics would you add, and how do you keep them from hurting performance?
Practice
Add a price filter to the filters example: a select with Any, 100 and 150 stored as max in the URL.
Make the cart example also react to the storage event, as a real cross-tab cart would.
Trade-offs
- ↔
SSR with a CDN gives the best SEO and LCP but needs cache invalidation when prices or stock change; client-rendered listings are simpler to host but rank and load worse.
- ↔
A local guest cart is instant and works offline but isn't on the user's other devices; a server cart follows the user but every change is a request. Most shops use local for guests and server once signed in.
- ↔
Hosted payment fields limit how much you can style them, in exchange for far less security and compliance risk.
Done when you can
I can write requirements for a shop with SEO and performance numbers.
I can keep filters, sort and page in the URL and reset the page on change.
I can explain SSR/ISR, canonical URLs and structured data for product pages.
I can sync a cart across tabs with useSyncExternalStore and BroadcastChannel.
I can design accessible checkout forms and keep card data out of my code.
I can plan funnel analytics that don't slow the page.