7.4 — Cart & checkout data¶
Module 7 · Lesson 4 · 🔴 Advanced · ~35 min
What you'll learn¶
- Why the uncacheable pages need a different playbook entirely
- Optimizing TTFB when nothing can be cached
- Payment SDK loading, tax/shipping calculation, and address validation
- The risk calculus: why checkout gets the tightest budgets and the least experimentation
Different rules apply here¶
| Browse pages (Home/PLP/PDP) | Cart & checkout | |
|---|---|---|
| Traffic share | 72% of sessions | 13% of sessions |
| Revenue at risk per session | Low | ~100% |
| Cacheable | Yes | No |
| Optimization lever | Caching, rendering strategy | Fewer bytes, fewer round trips |
| Third parties | Minimize | Zero (except payments) |
| Experimentation | Encouraged | Conservative, with guardrails |
| Budget | 200–260 KB JS | 180 KB JS |
The mindset shift: on a PDP you're optimizing to acquire attention. In checkout the user has already decided — you're optimizing to not lose them. Removing risk is worth more than adding speed.
Cart page TTFB¶
Nothing is cacheable, so all the wins come from doing less and doing it in parallel.
// ❌ 720ms: sequential, and re-pricing everything before anything renders
export default async function CartPage() {
const cartId = (await cookies()).get('cart_id')?.value;
const cart = await getCart(cartId); // 140ms
const products = await getProducts(cart.lineIds); // 180ms
const prices = await repriceCart(cart.id); // 210ms
const shipping = await estimateShipping(cart.id); // 130ms
const recommendations = await getCartRecs(cart.id); // 160ms
return <CartView … />;
}
// ✅ 190ms to first byte
export default async function CartPage() {
const cartId = (await cookies()).get('cart_id')?.value;
if (!cartId) return <EmptyCart />;
// Only what's needed for the shell. Reprice is authoritative and must block —
// showing a wrong total is worse than 190ms.
const cart = await getCartWithPricing(cartId); // one call, 190ms
return (
<>
<CartLines lines={cart.lines} />
<OrderSummary totals={cart.totals} />
<CheckoutButton disabled={!cart.lines.length} />
{/* Everything else streams */}
<Suspense fallback={<div className="h-14" />}>
<ShippingEstimate cartId={cartId} />
</Suspense>
<Suspense fallback={<div className="min-h-[280px]" />}>
<CartRecommendations cartId={cartId} />
</Suspense>
</>
);
}
Key decisions embedded there:
- Merge the cart + product + pricing calls into one backend call. For an uncacheable page,
round trips are the whole cost. A
getCartWithPricingendpoint that returns everything in one hop beats three well‑parallelized calls, because you pay one network latency instead of one plus the max. - Reprice server‑side, always, and block on it. The cart total is the contract. Never show a stale or optimistic total.
- Shipping estimate and recommendations stream. Neither blocks the user's ability to check out.
The reprice rule¶
// lib/cart.ts
import 'server-only';
/**
* ALWAYS recompute cart pricing server-side on view. The PDP price is
* marketing; the cart price is what the customer will be charged.
* This is the control that turns a stale-price incident into a non-event.
*/
export async function getCartWithPricing(cartId: string): Promise<PricedCart> {
const cart = await commerceApi.getCart(cartId, {
// Ask the backend to reprice as part of the read
reprice: true,
include: ['lines.product', 'lines.variant', 'totals', 'discounts'],
});
// Surface changes since the item was added, so nothing is a surprise at payment
const changed = cart.lines.filter((l) => l.priceAtAdd !== l.currentPrice);
if (changed.length) {
logInfo('cart_price_changed', { cartId, count: changed.length });
}
return { ...cart, priceChanges: changed };
}
// Show price changes explicitly — silently changing the total destroys trust
{cart.priceChanges.length > 0 && (
<div role="status" className="rounded-md bg-amber-50 p-3 text-sm">
Prices for {cart.priceChanges.length} item(s) have changed since you added them.
</div>
)}
Checkout as separate routes¶
The single biggest checkout performance decision:
app/checkout/
├── layout.tsx order summary sidebar (Server Component)
├── information/page.tsx contact + shipping address ~60 KB
├── shipping/page.tsx delivery method ~40 KB
└── payment/page.tsx payment ~180 KB (SDK)
versus a single‑page client wizard, where every user downloads the payment SDK at step 1.
At Aurora: 34% of users who start checkout never reach the payment step. Route splitting meant those users never downloaded 140 KB of payment SDK. First‑step JS went from 289 KB to 118 KB, and checkout‑start‑to‑information‑complete improved by 11%.
Additional benefits: real URLs (so analytics can see step drop‑off), a working back button, and the ability to server‑render each step's data independently.
Payment SDK loading¶
// app/checkout/shipping/page.tsx — prepare from the PREVIOUS step
export default async function ShippingStep() {
return (
<>
{/* Warm the connection while the user picks a delivery method */}
<link rel="preconnect" href="https://js.payments-vendor.com" crossOrigin="" />
{/* Prefetch the next route's RSC payload */}
<link rel="prefetch" href="/checkout/payment" as="document" />
<ShippingMethods />
</>
);
}
// app/checkout/payment/page.tsx
import dynamic from 'next/dynamic';
const PaymentElement = dynamic(() => import('@/components/payment-element'), {
ssr: false, // the SDK needs `window`
loading: () => <PaymentSkeleton />, // exact dimensions → no CLS
});
export default async function PaymentStep() {
// Create the payment intent server-side so the client doesn't need a round trip
const intent = await createPaymentIntent();
return (
<>
<OrderSummary />
<PaymentElement clientSecret={intent.clientSecret} />
</>
);
}
Payment SDK rules:
- Load it on the payment step only.
- Preconnect from the previous step.
- Create the payment intent server‑side; don't make the client fetch it after the SDK loads.
- The skeleton must match the rendered form's height exactly — a shifting payment form is the worst possible place for CLS.
- Never lazy‑load the SDK behind a user click on the payment step. Here the extra round trip is a conversion risk, not a saving.
Address, tax, and shipping calculation¶
These are network calls triggered by user input, which makes them INP‑adjacent.
Address autocomplete¶
'use client';
export function AddressAutocomplete({ onSelect }: Props) {
const [input, setInput] = useState('');
const [debounced, setDebounced] = useState('');
const abortRef = useRef<AbortController>();
useEffect(() => {
const t = setTimeout(() => setDebounced(input), 250);
return () => clearTimeout(t);
}, [input]);
const { data } = useSWR(
debounced.length >= 4 ? `/api/address/suggest?q=${encodeURIComponent(debounced)}` : null,
async (url) => {
abortRef.current?.abort();
abortRef.current = new AbortController();
return (await fetch(url, { signal: abortRef.current.signal })).json();
},
{ keepPreviousData: true },
);
return (
<>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
autoComplete="address-line1" // browser autofill is still faster than any API
aria-autocomplete="list"
/>
<ul className="min-h-0 max-h-56 overflow-auto">
{data?.slice(0, 5).map((s: AddressSuggestion) => (
<li key={s.id}><button type="button" onClick={() => onSelect(s)}>{s.label}</button></li>
))}
</ul>
</>
);
}
Native
autoCompletebeats any address API. A user with a saved address fills six fields in one tap. Get the attributes right (5.6) before adding an autocomplete vendor.
Tax and shipping recalculation¶
// ❌ Recalculating on every keystroke of the postcode field
onChange={(e) => { setPostcode(e.target.value); recalculateTax(e.target.value); }}
// ✅ On blur, once the field is complete, with a loading state on the total only
onBlur={(e) => {
const pc = e.target.value.trim();
if (isValidPostcode(pc, country)) {
startTransition(() => recalculateTax(pc));
}
}}
Show the pending state on the total row only — never disable the whole form or replace the summary with a skeleton. A checkout that greys itself out while calculating feels broken.
<div className="flex justify-between font-medium">
<span>Total</span>
<span className={isPending ? 'opacity-50 transition-opacity' : ''}>
{formatPrice(totals.grandTotal, currency, locale)}
</span>
</div>
Order submission¶
The one interaction where you must be conservative.
'use client';
export function PlaceOrderButton({ cartId }: { cartId: string }) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (submitting) return; // guard against double-submit
setSubmitting(true);
setError(null);
try {
// Idempotency key: a retried request must not create a second order
const idempotencyKey = crypto.randomUUID();
const result = await placeOrder({ cartId, idempotencyKey });
// Full navigation, not client-side routing — the confirmation must be
// a real page load so back-button behaviour is correct and analytics fire
window.location.href = `/order/${result.orderNumber}`;
} catch (err) {
setError(getUserMessage(err));
setSubmitting(false); // allow retry
}
};
return (
<>
<button onClick={submit} disabled={submitting} className="h-14 w-full …">
{submitting ? 'Placing order…' : 'Place order'}
</button>
{error && <p role="alert" className="mt-2 text-red-600">{error}</p>}
</>
);
}
Rules for order submission:
- Never optimistic. Payment can fail for a dozen reasons.
- Idempotency key on every attempt, generated once per submission attempt — a retry with the same key must return the existing order, not create a second one.
- Disable the button while submitting, and guard in code too (users double‑tap).
- Show real error messages, mapped from error codes to human language.
- Navigate with a full page load to the confirmation.
- Never put a timeout that's shorter than the payment processor's. A client timeout that fires while the charge succeeds produces the worst possible outcome: a charged customer with no order.
Session and cart data access¶
Cart reads happen on every page (the header badge). Make them cheap.
// ❌ A backend call on every page render just for the cart count
export default async function Layout({ children }) {
const cart = await getCart(cartId); // 140ms on EVERY page 💀
return <><Header cartCount={cart.totalQuantity} />{children}</>;
}
// ✅ Cart count from a signed cookie, updated on mutation.
// Zero backend calls; the layout stays static.
export default function Layout({ children }) {
return <><Header />{children}</>; // Header renders <CartCount /> client-side
}
// Set a lightweight count cookie whenever the cart changes
'use server';
export async function addToCart(/* … */) {
const cart = await commerceApi.addLine(/* … */);
(await cookies()).set('cart_count', String(cart.totalQuantity), {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'lax',
path: '/',
// Not httpOnly: the client store reads it for instant hydration.
// It contains no sensitive data — just a number.
});
return { ok: true };
}
Trade‑off: the count can drift if the cart changes in another tab or expires server‑side. Resolve
it by syncing the client store on visibilitychange and on the cart page itself.
Third parties on checkout: the policy¶
Allowed: payment SDK, fraud/risk (if contractually required)
Not allowed: analytics, session replay, chat, A/B testing, ads, heatmaps,
affiliate pixels, surveys, personalization
Analytics on checkout goes server‑side (2.3) — you still get the funnel data, with better completeness (no ad blockers) and zero client cost.
This is a policy conversation, not a technical one. The argument that works: "Checkout is 5% of sessions and ~100% of revenue. A tag that breaks a browse page costs us a session; a tag that breaks checkout costs us the order. We collect the same data server‑side."
Aurora's checkout results¶
| Change | Effect |
|---|---|
| Route‑split checkout steps | First‑step JS 289 → 118 KB |
| Payment SDK scoped to the payment step | 140 KB avoided for 34% of users |
| Uncontrolled form inputs | INP 260 → 140 ms |
| Merged cart+pricing backend call | Cart TTFB 720 → 190 ms |
| Removed all non‑payment third parties | TBT −480 ms |
| Server‑side analytics | Funnel data completeness 71% → 98% |
| Cart count from cookie | −140 ms on every page in the app |
Measured conversion effect of the checkout work: +2.1% relative, in a 3‑week A/B holdback. At Aurora's volume that's ~$21M/yr — the largest single result in the whole programme, from the lowest‑traffic pages.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Single‑page checkout wizard | Payment SDK for everyone, including the 34% who leave |
| Trusting the PDP price at checkout | Wrong charges, chargebacks, support load |
| Optimistic order placement | Catastrophic |
| No idempotency key | Duplicate orders on retry |
| Client timeout shorter than the processor's | Charged customer, no order |
| Recalculating tax per keystroke | INP + backend load |
| Disabling the whole form while calculating | Feels broken |
| Analytics/chat/A/B on checkout | Risk with no upside |
| Backend cart call in the root layout | +140 ms on every page in the app |
| Client‑side navigation to order confirmation | Broken back button, missed analytics |
Lab 7.4 — Checkout audit¶
- Measure First Load JS for each checkout step. If step 1 includes the payment SDK, split the routes.
- Count backend calls for a cart page render. Merge what you can into one call.
- Verify repricing happens server‑side on cart view and again before payment authorization.
- Inventory third parties on checkout. Remove everything except payment and required fraud.
- Profile typing in the checkout form. Convert to uncontrolled inputs if you see a commit per keystroke.
- Test double‑submit: click "Place order" twice quickly. If two orders are created, add an idempotency key today.
- Test the failure path: force a payment decline and confirm the error is clear, the button re‑enables, and no order was created.
- Check the cart count path — is your root layout making a backend call?
Checklist¶
- Checkout split into routes; payment SDK on the payment step only
- Preconnect to the payment origin from the previous step
- Cart repriced server‑side on every view; price changes surfaced explicitly
- One backend call for the cart shell where possible
- Non‑blocking sections (shipping estimate, recs) streamed
- Uncontrolled form inputs; validation on blur
- Tax/shipping recalculated on blur, with pending state on the total only
- Order submission: never optimistic, idempotency key, double‑submit guard
- Client timeout longer than the processor's
- Zero non‑essential third parties; analytics server‑side
- Cart count served without a backend call on every page