The cart interface
The cart interface has one job: let somebody change their mind quickly, and never make them wonder whether it worked.
That is harder than it sounds on a phone, on mobile data, in a shop where stock changes under you.
The browser never talks to the API
Every cart change in this application is a server action. The browser calls a function that runs on the Next server; the Next server calls the API.
// apps/web/src/app/actions/cart.ts
"use server";
export async function addToCart(variantId: string, quantity = 1) {
return run(() =>
apiRequest("/cart/items", { method: "POST", body: { variantId, quantity } }),
);
}
Four things this buys you, and they are the reason it is worth the indirection:
The session cookie stays httpOnly. If the browser called the API directly,
the cookie would have to be readable by JavaScript or sent cross-origin with
credentials. Neither is necessary here.
No CORS credentials dance. Same origin, always.
The API's address is never in the bundle. API_URL has no NEXT_PUBLIC_
prefix, so it does not ship to the browser. On a platform with private
networking, the address the server uses is not one the browser could reach
anyway.
A <form action={...}> works without JavaScript. On a cheap Android on a bad
connection, the page is interactive before the bundle has finished downloading.
The cookie has to be carried by hand
A fetch from the Next server is a fresh connection from a different machine.
The browser's cookies are not on it.
const store = await cookies();
const outgoing = store
.getAll()
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join("; ");
const response = await fetch(`${API_URL}${path}`, {
headers: { ...(outgoing ? { cookie: outgoing } : {}) },
cache: "no-store",
});
await adoptCookies(response, store);
Cookies out, and cookies back. When the API mints a cart token, that
Set-Cookie has to be re-issued to the browser or the next request looks like a
different visitor.
Two details worth knowing:
for (const header of response.headers.getSetCookie()) {
getSetCookie(), not get("set-cookie"). A response can set several
cookies. get joins them into one string, and you cannot split it again
reliably because an Expires value contains a comma.
And cache: "no-store", always. A cart cached for one visitor and served to
another is the worst bug in this file's neighbourhood.
Revalidation, or the number that lies
The cart appears in two places: the page, and the count beside the basket in the header. Change a quantity and both must move.
revalidatePath("/", "layout");
The "layout" argument matters. revalidatePath("/") invalidates the page at
/ and nothing else; revalidatePath("/", "layout") invalidates the root
layout and everything inside it — which is where the header lives.
Skip it and the cart page updates while the header still says 3. Nobody reports that bug. They just stop trusting the number.
Pending state without breaking the button
const [pending, startTransition] = useTransition();
const onClick = () => {
startTransition(async () => {
const result = await addToCart(variantId, 1);
if (result.ok) {
setAdded(true);
setTimeout(() => setAdded(false), 2000);
} else {
setError(result.message);
}
});
};
useTransition gives a pending flag that flips after the click has been
handled. That ordering is the whole point.
The tempting version sets disabled from an onClick handler, and on a phone
the re-render can land before the browser has finished dispatching the event —
so the tap is swallowed and the button sits there looking broken. Module 4 has
the same lesson about submit buttons; it is the same trap.
The "Added ✓" that reverts after two seconds is not decoration. Without it, nothing on screen changes except a small number in the header, and people tap again.
<AddToCart key={selected.id} variantId={selected.id} disabled={!selected.inStock} />
Keyed on the variant, so switching from 500 g to 1 kg clears "Added ✓" rather than claiming the new size is already in the basket.
Zero means remove
const result =
next <= 0
? await removeFromCart(variantId)
: await setCartQuantity(variantId, next);
The API accepts quantity: 0 on PATCH and treats it as a removal. The
alternative — rejecting 0 and making the client call DELETE — turns every
quantity stepper into two code paths, and the one that fires when somebody taps
minus on the last item is the one nobody tests.
On the server, the removal is a deleteMany, not a delete:
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id, variantId } });
delete throws when the row has already gone. Two taps on minus arriving out of
order is completely normal on a phone, and the second one should be a no-op, not
a 500.
The plus button knows when to stop
<button onClick={() => change(quantity + 1)} disabled={pending || quantity >= max}>
where max is the real stock when there is a problem with that line, and the
per-item cap otherwise.
An interface that lets somebody tap a button and then tells them off for it is an interface that made them do something wrong. If the shop has three, the plus button stops at three.
Problems the customer can act on
The API returns issues alongside the lines:
export interface CartIssue {
variantId: string;
code: CartIssueCode; // OUT_OF_STOCK | REDUCED_STOCK | UNAVAILABLE
available: number;
message: string;
}
Three states, because they need three different responses. Gone for good
(UNAVAILABLE — the shop stopped selling it), temporarily gone
(OUT_OF_STOCK), and fewer than you asked for (REDUCED_STOCK).
message: `Only ${variant.stock} of ${label} left.`
Say the number. "Only 3 left" tells somebody what to do. "Not enough stock" makes them guess, and the guess is usually wrong.
The cart shows these at the top, on the affected line, and hides the checkout button until they are resolved:
{blocking ? (
<p>Fix the items above and the checkout button will come back.</p>
) : (
<Link href={user ? "/checkout" : "/account/login?next=%2Fcheckout"}>
{user ? "Checkout" : "Sign in to check out"}
</Link>
)}
Note the second half. The button says what will actually happen. A "Checkout" button that turns out to be a sign-in form is a small lie, and it is the exact moment people leave.
The empty cart
if (cart.lines.length === 0) {
return (
<div className="py-12 text-center">
<h1 className="text-xl font-semibold">Your cart is empty.</h1>
<Link href="/products">Start shopping</Link>
</div>
);
}
Always give an empty state a way out. A dead end is a page somebody closes.
Two small accessibility debts, paid
aria-label={
cart.itemCount === 0
? "Cart, empty"
: `Cart, ${cart.itemCount} ${cart.itemCount === 1 ? "item" : "items"}`
}
Without it the link announces as "Cart1", because the count sits in an adjacent span and nothing separates them.
<span className="w-8 text-center text-sm" aria-live="polite">
{quantity}
</span>
aria-live="polite" announces the new quantity after a tap. Without it a screen
reader user taps plus and hears nothing at all.
Check your work
Why cart changes are server actions: the session cookie stays httpOnly,
there is no CORS, the API address stays out of the bundle, and a plain form
works without JavaScript.
Why cookies are copied onto the outgoing fetch: a request from the Next server is a different connection and carries none of the browser's cookies.
Why getSetCookie(): several cookies can be set at once, and joining them
into one string cannot be undone because Expires contains a comma.
Why revalidatePath("/", "layout"): the basket count lives in the layout,
and revalidating only the page leaves it showing a stale number.
Why useTransition rather than disabled in onClick: the pending flag
flips after the click is handled, so the tap is never swallowed.
Why the AddToCart button is keyed on the variant: switching pack size must clear "Added ✓".
Why quantity: 0 means remove: one code path instead of two, and the second
one would be the untested one.
Why deleteMany rather than delete: a repeated tap must be a no-op, not a
500.
Why the plus button is capped at stock: never let somebody do a thing and then tell them off for it.
Why the issue message names the number: "Only 3 left" is actionable; "not enough stock" is a riddle.
Practice
- Add something to the cart and confirm the header count changes without a manual refresh.
- Remove the
"layout"argument fromrevalidatePathand watch the count go stale. - Throttle to Slow 3G and tap "Add to cart". Confirm the button says "Adding…" and that a second tap does not double the quantity.
- Switch pack size on a product page after adding one. Confirm "Added ✓" clears.
- Set a variant's stock to 2 in the database. Put 5 in your cart first, then reload and read what the cart says.
- With that line in a bad state, confirm the checkout button is gone and the plus button stops at 2.
- Tap minus on a one-item line twice quickly. Confirm no error.
- Sign out and open the cart. Confirm the button says "Sign in to check out" and goes somewhere sensible.
- Turn JavaScript off and confirm the page still renders the cart correctly.
- Navigate the cart with a screen reader and confirm the basket count and the quantity are both announced.
Next: stock, and why it is the hardest thing in this course.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship