Skip to content

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

Next: 10.6 Mobile & low‑end devices