10.5 — Cart & checkout playbook¶
Module 10 · Lesson 5 · 🟡 Intermediate · ~30 min
13% of sessions, ~100% of completed revenue. The rules are different here: risk reduction beats speed, and speed comes from removing things rather than caching them.
Technical depth in 7.4 and 5.6; this is the assembled playbook.
Profile¶
| Share of sessions | ~13% (8% cart, 5% checkout) |
| Revenue at risk per session | ~100% |
| Cacheability | None |
| LCP element | Cart line items, or the checkout form container |
| Dominant risks | Payment SDK weight, form INP, third parties, any bug at all |
| Rendering strategy | SSR, no-store, minimal JS |
Budgets¶
| Metric | Cart | Checkout |
|---|---|---|
| LCP | ≤ 2.0 s | ≤ 1.8 s |
| INP | ≤ 150 ms | ≤ 150 ms |
| CLS | ≤ 0.02 | ≤ 0.01 |
| TTFB | ≤ 500 ms | ≤ 500 ms |
| JS (gz) | ≤ 200 KB | ≤ 180 KB |
| Third‑party JS | ≤ 30 KB | 0 KB (payments excepted) |
Cart¶
// app/cart/page.tsx
export const dynamic = 'force-dynamic'; // never cached, deliberately
export default async function CartPage() {
const cartId = (await cookies()).get('cart_id')?.value;
if (!cartId) return <EmptyCart />;
// ONE call: lines + products + repriced totals. Round trips are the whole
// cost on an uncacheable page.
const cart = await getCartWithPricing(cartId);
if (!cart.lines.length) return <EmptyCart />;
return (
<main className="grid lg:grid-cols-[1fr_380px] lg:gap-12">
<section>
<h1>Your bag ({cart.totalQuantity})</h1>
{/* Price changes surfaced explicitly — never a silent total change */}
{cart.priceChanges.length > 0 && (
<div role="status" className="mt-4 rounded-md bg-amber-50 p-3 text-sm">
Prices for {cart.priceChanges.length} item(s) have changed since you added them.
</div>
)}
<ul className="mt-6 divide-y">
{cart.lines.map((line) => <CartLine key={line.id} line={line} />)}
</ul>
</section>
<aside className="lg:sticky lg:top-4 lg:self-start">
<OrderSummary totals={cart.totals} />
<CheckoutButton />
{/* Not blocking checkout — streamed */}
<Suspense fallback={<div className="h-14" />}>
<ShippingEstimate cartId={cartId} />
</Suspense>
</aside>
{/* Below the fold, streamed, never blocking */}
<Suspense fallback={<div className="min-h-[300px]" />}>
<CartRecommendations cartId={cartId} />
</Suspense>
</main>
);
}
The quantity stepper¶
The cart's main interaction, and a classic Server Action pitfall (they run sequentially).
'use client';
export function QuantityStepper({ lineId, initial, max = 10 }: Props) {
const [qty, setQty] = useState(initial);
const timer = useRef<ReturnType<typeof setTimeout>>();
const [isPending, startTransition] = useTransition();
const change = (delta: number) => {
const next = Math.max(1, Math.min(max, qty + delta));
if (next === qty) return;
setQty(next); // instant UI
clearTimeout(timer.current);
timer.current = setTimeout(() => { // one server call after they stop
startTransition(() => { void updateQuantityAction(lineId, next); });
}, 400);
};
return (
<div className="flex items-center gap-3">
<button onClick={() => change(-1)} disabled={qty <= 1} aria-label="Decrease quantity">−</button>
<span className="w-8 text-center tabular-nums" aria-live="polite">{qty}</span>
<button onClick={() => change(1)} disabled={qty >= max} aria-label="Increase quantity">+</button>
{isPending && <Spinner className="h-4 w-4" aria-label="Updating" />}
</div>
);
}
Show the pending state on the total, not on the whole page. A cart that greys out while recalculating feels broken.
Checkout¶
Route splitting is the main decision¶
app/checkout/
├── layout.tsx order summary (Server Component)
├── information/page.tsx contact + shipping address ~60 KB
├── shipping/page.tsx delivery method ~40 KB
└── payment/page.tsx payment +140 KB SDK
At Aurora, 34% of users who start checkout never reach payment. Route splitting means those users never download the payment SDK: first‑step JS 289 KB → 118 KB.
// app/checkout/layout.tsx — shared, server-rendered, no client JS
export default async function CheckoutLayout({ children }) {
const cartId = (await cookies()).get('cart_id')?.value;
if (!cartId) redirect('/cart');
const cart = await getCartWithPricing(cartId);
return (
<div className="grid lg:grid-cols-[1fr_380px] lg:gap-16">
<div>
<CheckoutSteps /> {/* server-rendered; current step from the pathname */}
{children}
</div>
<OrderSummary totals={cart.totals} lines={cart.lines} collapsibleOnMobile />
</div>
);
}
The information step¶
// app/checkout/information/page.tsx
import { submitInformation } from '@/app/actions/checkout';
export default async function InformationStep() {
const saved = await getSavedAddress(); // null for guests
return (
<>
{/* Warm the next step while the user fills this one */}
<link rel="prefetch" href="/checkout/shipping" as="document" />
{/* Works before hydration — a tap here is never lost */}
<form action={submitInformation} className="space-y-4">
<input name="email" type="email" required autoComplete="email"
defaultValue={saved?.email} inputMode="email" />
<div className="grid grid-cols-2 gap-4">
<input name="firstName" required autoComplete="given-name" defaultValue={saved?.firstName} />
<input name="lastName" required autoComplete="family-name" defaultValue={saved?.lastName} />
</div>
<input name="address1" required autoComplete="address-line1" defaultValue={saved?.address1} />
<input name="address2" autoComplete="address-line2" defaultValue={saved?.address2} />
<div className="grid grid-cols-3 gap-4">
<input name="city" required autoComplete="address-level2" defaultValue={saved?.city} />
<input name="state" required autoComplete="address-level1" defaultValue={saved?.state} />
<input name="postalCode" required autoComplete="postal-code"
inputMode="numeric" defaultValue={saved?.postalCode} />
</div>
<SubmitButton>Continue to shipping</SubmitButton>
</form>
</>
);
}
Every field uncontrolled, every field with autoComplete and inputMode. Autofill saves the
user ~20 interactions, which is worth more than any millisecond you'll shave elsewhere on this page.
The payment step¶
// app/checkout/shipping/page.tsx — prepare from the PREVIOUS step
export default async function ShippingStep() {
return (
<>
<link rel="preconnect" href="https://js.payments-vendor.com" crossOrigin="" />
<link rel="prefetch" href="/checkout/payment" as="document" />
<ShippingMethodForm />
</>
);
}
// app/checkout/payment/page.tsx
import dynamic from 'next/dynamic';
const PaymentElement = dynamic(() => import('@/components/payment-element'), {
ssr: false,
loading: () => <PaymentSkeleton />, // exact height of the real form
});
export default async function PaymentStep() {
// Create the intent server-side so the client doesn't need a round trip after the SDK loads
const intent = await createPaymentIntent();
return <PaymentElement clientSecret={intent.clientSecret} />;
}
Order submission¶
'use client';
export function PlaceOrderButton() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (submitting) return; // double-tap guard
setSubmitting(true);
setError(null);
try {
const result = await placeOrder({ idempotencyKey: crypto.randomUUID() });
window.location.href = `/order/${result.orderNumber}`; // full navigation
} catch (err) {
setError(getUserMessage(err));
setSubmitting(false); // allow retry
}
};
return (
<>
<button onClick={submit} disabled={submitting} className="h-14 w-full rounded-full bg-neutral-900 text-white">
{submitting ? 'Placing order…' : 'Place order'}
</button>
{error && <p role="alert" className="mt-3 text-sm text-red-600">{error}</p>}
</>
);
}
Never optimistic. Always idempotent. Always a full navigation to confirmation.
The third‑party policy¶
Checkout allowed: payment SDK, fraud/risk if contractually required
Checkout NOT allowed: analytics, session replay, chat, A/B testing, ads,
heatmaps, affiliate pixels, surveys, personalization
Analytics runs server‑side (2.3) — you get the same funnel data with better completeness and zero client cost.
The argument that wins this: "Checkout is 5% of sessions and ~100% of revenue. A tag that breaks a browse page costs a session; a tag that breaks checkout costs the order. We collect the same data server‑side."
Guest checkout and the login wall¶
Not a performance topic on its face, but a forced account creation step is measurably the largest single drop‑off in most checkouts — larger than any latency effect you'll ever fix.
If you're optimizing checkout and there's a login wall, raise it. The performance work is worth 1–3% relative; removing a forced login is often worth 10–20%.
Diagnosis order¶
Cart TTFB > 500ms?
├─ How many backend calls? → merge into one, [7.4]
├─ Is repricing sequential with the cart read? → one call with reprice: true
└─ Are recs/shipping blocking? → Suspense them
Checkout INP > 150ms?
├─ Are inputs controlled? → uncontrolled + FormData, [5.6]
├─ Validation on every keystroke? → on blur only
├─ Tax recalculating per keystroke? → on blur, when the field is complete
└─ Any third-party scripts present? → remove them
Checkout JS > 180KB?
├─ Is the payment SDK on step 1? → route-split
├─ Is a date picker / address vendor bundled? → lazy or native
└─ Is the cart store / analytics in the layout? → scope it out
Checkout CLS > 0.01?
├─ Payment iframe resizing? → skeleton at the exact height
├─ Validation errors pushing content? → reserve error row height
└─ Order summary expanding on mobile? → it's interaction-triggered; verify < 500ms
Aurora's results¶
| Metric | Before | After |
|---|---|---|
| Cart TTFB | 720 ms | 190 ms |
| Checkout step 1 JS | 289 KB | 118 KB |
| Checkout INP | 260 ms | 128 ms |
| Checkout CLS | 0.03 | 0.01 |
| Third parties on checkout | 7 | 1 (payments) |
| Funnel data completeness | 71% | 98% |
| Measured conversion lift | — | +2.1% relative (14‑day A/B) |
≈ $21M/yr from the lowest‑traffic pages in the site. Traffic share is a bad proxy for where to spend performance effort — revenue risk per session is the better one.
Checklist¶
Cart
- [ ] force-dynamic, never cached
- [ ] One backend call for the shell (cart + products + repriced totals)
- [ ] Server‑side repricing on every view; price changes surfaced
- [ ] Quantity changes debounced, optimistic UI
- [ ] Recommendations and shipping estimate streamed
- [ ] Cart count available without a backend call on other pages
Checkout
- [ ] Split into routes; payment SDK on the payment step only
- [ ] Preconnect + prefetch from the previous step
- [ ] All inputs uncontrolled, with autoComplete and inputMode
- [ ] Validation on blur and submit; server validation always
- [ ] Tax/shipping recalculated on blur, pending state on the total only
- [ ] Order submission: never optimistic, idempotency key, double‑submit guard
- [ ] Full navigation to the confirmation page
- [ ] Zero non‑payment third parties
- [ ] Analytics server‑side
- [ ] Works with JavaScript disabled
- [ ] Guest checkout available