Shopping Cart in Next.js 16: Where Cart State Should Live
A cookie, a database row, or React Context? The three cart architectures compared, the add-to-cart Server Action, and the badge that quietly breaks static rendering.
A shopping cart in Next.js 16 needs one architectural decision before any code: where the cart lives. A cookie holds it for anonymous shoppers with zero client state. A database row holds it across devices for signed-in ones. React Context plus localStorage — the pattern most tutorials reach for — holds it worst of the three.
This post covers the three options, the Server Action that writes to a cart, and the cart badge that does not turn your whole storefront dynamic. The bugs are ones we hit building the cart flows in our own shop templates.
The question that decides your architecture
Ask this before writing anything:
Does a cart need to survive the shopper switching devices?
If no, a cookie is enough, and you save yourself a database, a session table, and a merge routine. If yes, you need a row keyed to a user or a session id, and you need to decide what happens when an anonymous cart meets a signed-in one at login.
Most storefronts answer "no" for anonymous shoppers and "yes" the moment someone signs in. That is a hybrid, and it is the right default — but build the cookie half first. It works for every visitor, including the 90%+ who never sign in.
Option 1: the cookie cart
A cart is a short list of ids and quantities. That fits in a cookie with room to spare, and it means the server can render the cart with no client-side fetching and no loading spinner.
// src/lib/cart.ts
import { cookies } from "next/headers";
export type CartLine = { sku: string; qty: number };
const COOKIE = "cart";
export async function readCart(): Promise<CartLine[]> {
const raw = (await cookies()).get(COOKIE)?.value;
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
// Never trust a cookie's shape — the user can edit it.
if (!Array.isArray(parsed)) return [];
return parsed
.filter((l) => typeof l?.sku === "string" && Number.isInteger(l?.qty))
.map((l) => ({ sku: l.sku, qty: Math.min(Math.max(l.qty, 1), 99) }));
} catch {
return [];
}
}
cookies() is async in Next 16 — await it. The validation is not paranoia: a cookie is user-controlled input. A shopper who edits qty to -5 or 1e9 and hits checkout is the cheapest bug report you will ever get, and clamping on read costs one line.
Two things the cookie must not contain: prices and product names. Store the sku and the quantity, then look up the price server-side at render and again at checkout. A cart cookie carrying price: 9.99 is an invitation to edit it.
export async function cartWithPrices() {
const lines = await readCart();
return lines
.map((line) => {
const product = getProduct(line.sku); // your catalog or DB
return product ? { ...line, product, total: product.price * line.qty } : null;
})
.filter((l) => l !== null);
}
Option 2: the database cart
Once a shopper signs in, the cart should follow them. The shape is a table keyed by user id, and the only interesting part is the merge:
export async function mergeCartOnLogin(userId: string) {
const cookieLines = await readCart();
if (cookieLines.length === 0) return;
for (const line of cookieLines) {
await db.cartLine.upsert({
where: { userId_sku: { userId, sku: line.sku } },
// Take the larger quantity rather than summing. Summing double-counts
// the shopper who added an item, signed in, and found it there already.
update: { qty: { set: Math.max(line.qty, await currentQty(userId, line.sku)) } },
create: { userId, sku: line.sku, qty: line.qty },
});
}
(await cookies()).delete("cart");
}
Decide the merge rule deliberately and write it down. "Sum both carts" is the intuitive choice and it is usually wrong — it silently doubles quantities for the most common real sequence, which is add-then-sign-in.
Why not Context plus localStorage
This is the pattern in most Next.js cart tutorials, and it has three problems that only show up in production.
The cart renders empty first. localStorage does not exist on the server, so the server renders an empty cart, then the client corrects it after hydration. Shoppers see a cart badge flash from empty to three items. It is the same class of bug as the flash of wrong theme in dark mode, and it has the same cause: state the server cannot see.
The server cannot use it. Every price calculation, stock check and shipping estimate has to happen on the client or via an extra round trip, because the authoritative cart lives in a browser API.
It makes a client boundary out of your whole tree. A CartProvider at the root turns every descendant into part of a client-rendered subtree.
Context is still the right tool for the open/closed state of the cart drawer — that is genuine UI state. It is the wrong tool for the cart's contents.
The add-to-cart Server Action
With the cart on the server, adding an item is a Server Action and needs no client JavaScript at all:
// src/lib/actions/cart.ts
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { readCart } from "@/lib/cart";
export async function addToCart(formData: FormData) {
const sku = String(formData.get("sku") ?? "");
// Validate against the catalog, not against the form.
if (!getProduct(sku)) return;
const lines = await readCart();
const existing = lines.find((l) => l.sku === sku);
const next = existing
? lines.map((l) => (l.sku === sku ? { ...l, qty: Math.min(l.qty + 1, 99) } : l))
: [...lines, { sku, qty: 1 }];
(await cookies()).set("cart", JSON.stringify(next), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 30,
path: "/",
});
revalidatePath("/cart");
}
The form is a plain form:
<form action={addToCart}>
<input type="hidden" name="sku" value={product.sku} />
<button type="submit">Add to cart</button>
</form>
That works with JavaScript disabled and before hydration finishes. Wrap the button in useFormStatus when you want a pending state, but the baseline already functions.
httpOnly: true is worth calling out. It means client JavaScript cannot read the cart cookie — which is fine, because nothing on the client needs to. If you find yourself removing httpOnly to read the cart in a component, that component should have been a Server Component.
The cart badge without going fully dynamic
Here is the trap. Your header shows "Cart (3)". The header is in the root layout. Reading cookies() in the layout opts every route into dynamic rendering, and your statically generated product pages quietly become server-rendered on every request.
Check the build output — ○ is static, ƒ is dynamic:
Route (app)
├ ○ /shop
├ ● /shop/[slug]
└ ƒ /cart
If your product pages show ƒ after adding a cart badge, that is what happened.
The fix is to isolate the dynamic part. Render the badge in its own component wrapped in <Suspense>, so the shell stays static and only the badge streams in:
// In the header — the layout itself never reads cookies.
<Suspense fallback={<CartBadgeSkeleton />}>
<CartBadge />
</Suspense>
// src/components/CartBadge.tsx — a Server Component
import { readCart } from "@/lib/cart";
export default async function CartBadge() {
const lines = await readCart();
const count = lines.reduce((n, l) => n + l.qty, 0);
return <span aria-label={`${count} items in cart`}>{count}</span>;
}
The product page stays prerendered and cacheable. Only the badge is per-request.
Handing the cart to checkout
The cart's job ends at checkout. Whatever processor you use, the rule is the same: recompute every price server-side from your catalog before creating the payment session. Never send a total the browser calculated. Rendering those amounts is its own problem — currency formatting in Next.js covers integer cents and pinning the locale so the server and the client agree on the string.
"use server";
export async function checkout() {
const lines = await cartWithPrices(); // prices from the catalog, not the cookie
const session = await createCheckoutSession({
lineItems: lines.map((l) => ({ sku: l.sku, qty: l.qty, price: l.product.price })),
});
redirect(session.url);
}
For the payment side of this — signed webhooks, fulfilment, and why the webhook rather than the success page is what grants access — see selling a digital product with LemonSqueezy checkout.
Mistakes that cost us time
| Mistake | Symptom | Fix |
|---|---|---|
| Price stored in the cart cookie | Shopper edits it and pays less | Store sku + qty only; look up price server-side |
cookies() read in the root layout | Every route becomes ƒ | Isolate in a <Suspense>-wrapped component |
| Summing quantities on cart merge | Items double after login | Take the max, not the sum |
| No clamp on quantity | qty: -3 produces a negative total | Clamp on read and on write |
Cart in Context + localStorage | Badge flashes empty then fills | Move cart state to a cookie |
revalidatePath forgotten | Cart page shows stale contents | Revalidate after every mutation |
Cookie without httpOnly | Cart readable by any injected script | Set httpOnly; read it server-side |
Frequently asked questions
How big can a cart cookie get?
Browsers cap a cookie at roughly 4KB. Storing {sku, qty} pairs, that is well over a hundred line items — far past the point where you should have moved to a database anyway. If you are near the limit, store a cart id in the cookie and the lines in a table.
Do I need a database for an anonymous cart? No. A cookie carries an anonymous cart perfectly well and removes a whole storage layer. Add the database when carts need to survive a device change, which in practice means when the shopper has an account.
Does a cookie cart work with static product pages?
Yes — that is the main reason to prefer it. The product page stays statically generated; only the cart badge and the cart page itself are per-request. Keep cookies() out of shared layouts and the static rendering survives.
Should add-to-cart be a Server Action or an API route? A Server Action. It works without client JavaScript, it needs no fetch wrapper or error handling of your own, and it colocates with the code that reads the cart. Reach for a route handler when a non-browser client needs the same endpoint.
Where should stock validation happen? At checkout, server-side, against your source of truth — never in the cart UI alone. Checking stock when the item is added is good UX, but the cart can sit for hours. The check that matters is the one immediately before payment.
Starting from a finished storefront
Cart state, catalog nav, variant pricing and a checkout hand-off are a few weeks of work once the edge cases land — and the edge cases above are the ones that surface after launch, not during.
Our Next.js shop templates ship the flow already built. ASoc Muse is a fashion storefront with a catalog, lookbook and a working cart; ASoc Keycap is a mechanical-keyboard store with switches, keycaps and accessories; ASoc Harvest is an organic dairy shop built around variant pricing, which is where most cart implementations get complicated.
Browse all Next.js shop templates, or the Tailwind shop templates if the design system matters more than the framework.
