Skip to content

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:

  1. Merge the cart + product + pricing calls into one backend call. For an uncacheable page, round trips are the whole cost. A getCartWithPricing endpoint that returns everything in one hop beats three well‑parallelized calls, because you pay one network latency instead of one plus the max.
  2. Reprice server‑side, always, and block on it. The cart total is the contract. Never show a stale or optimistic total.
  3. 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 autoComplete beats 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

  1. Measure First Load JS for each checkout step. If step 1 includes the payment SDK, split the routes.
  2. Count backend calls for a cart page render. Merge what you can into one call.
  3. Verify repricing happens server‑side on cart view and again before payment authorization.
  4. Inventory third parties on checkout. Remove everything except payment and required fraud.
  5. Profile typing in the checkout form. Convert to uncontrolled inputs if you see a commit per keystroke.
  6. Test double‑submit: click "Place order" twice quickly. If two orders are created, add an idempotency key today.
  7. Test the failure path: force a payment decline and confirm the error is clear, the button re‑enables, and no order was created.
  8. 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

Next: Module 8 — Advanced techniques