RizTech Academy logo
RizTech Academy
Kirana Store: Cart and CheckoutLesson 1 of 535 min

Modelling a cart that survives a refresh and a login

A cart looks like the easy part. It is a list of things somebody wants to buy.

It is not the easy part. A cart has to survive a refresh, a closed tab, a phone that died, a switch from mobile data to wifi, and — the one that catches everybody — the moment you ask the customer to sign in.

Where a cart can live

Four options, and only one of them is right for a shop.

Where Survives refresh Survives new device Shop can see it Cost
React state no no no free
localStorage yes no no free
A cookie yes no no 4 KB limit, sent on every request
The database yes yes yes a row and a query

React state alone is not a cart, it is a list that vanishes on refresh. People refresh. People also tap a product link from WhatsApp, which is a fresh page load.

localStorage survives a refresh, and it is where a lot of tutorials stop. It fails the moment the customer picks up their phone instead of their laptop, and it fails silently — the cart is not empty, it is elsewhere, which is far more confusing than losing it outright.

A cookie has the same problem plus a worse one: it is sent on every single request, including every image. A ten-item cart in a cookie is a few hundred bytes added to hundreds of requests, on a connection that is already slow.

The database costs a query and earns everything else. The shop owner can see abandoned carts. The customer's basket follows them from the bus to the sofa. And prices and stock come from the same place they are checked, rather than from a copy the browser made twenty minutes ago.

That is decision 0003 in the reference repository: the cart lives in the database, not a cookie.

But a cart exists before the customer does

Here is the part that makes it interesting. Somebody lands on the shop from a search result, adds atta, and has no account. You cannot key their cart on a user id, because there is no user.

Asking them to sign in first is not an option either. Every shop that has measured it finds the same thing: a sign-in wall before the cart loses most of the people who hit it. They have not decided to buy yet. They are still deciding whether to bother.

So a cart has two possible owners:

model Cart {
  id        String  @id @default(cuid())
  userId    String?
  user      User?   @relation(fields: [userId], references: [id], onDelete: Cascade)
  sessionId String? @unique

  items CartItem[]
  // …
}

Both nullable, exactly one set. A signed-in cart has a userId. An anonymous cart has a sessionId — a random token the browser holds in a cookie.

The token is not the cart id

This looks like pointless indirection until you think about what the cookie is.

// apps/api/src/cart/cart-cookie.ts
export function newCartToken(): string {
  return randomBytes(24).toString("base64url");
}

The cookie is a bearer credential: anybody holding it can read and change that cart. Cart ids leak. They appear in URLs while you are debugging, in server logs, in error reports sent to Sentry, in screenshots pasted into Slack. A random token that exists only in an httpOnly cookie and a database column never appears anywhere a person can see.

response.cookie(CART_COOKIE, token, {
  httpOnly: true,   // JavaScript cannot read it, so an XSS bug cannot steal it
  sameSite: "lax",
  secure: process.env.NODE_ENV === "production",
  path: "/",
  maxAge: THIRTY_DAYS_MS,  // people fill a basket and come back at the weekend
});

A read must never create a cart

This one is easy to get wrong and expensive to discover.

@Get()
async view(@Req() request: Request, @CurrentUser() user?: AuthUser) {
  const owner = this.readOwner(request, user);
  if (!owner) return EMPTY_CART;
  return this.cart.view(owner);
}

If GET /cart created a cart row when there was not one, then every crawler that touches the storefront would leave an empty cart behind. Googlebot, the WhatsApp link preview fetcher, your own uptime monitor hitting the page every minute. Thousands of rows that are not carts, that nobody will ever clean up, that make "how many people abandoned a cart this week?" unanswerable.

Reads read. Writes create. Only POST /cart/items mints a token, and only because it has something to put in it.

The line total is never stored

linePaise: variant.pricePaise * item.quantity,

Computed on read, every time. It is tempting to store it — one less multiplication — and the moment you do, you have a number that can disagree with the price printed next to it. The shop raises the price of dal, and a cart from yesterday shows ₹95 each and ₹210 for two.

The same reasoning is why CartItem stores only a variantId and a quantity. The name, the price and the pack size all come from the variant, live. A cart shows today's shop, not a snapshot of last Tuesday's.

An order is the opposite, and module 13 covers that: an order must snapshot everything, because it is a record of a transaction that happened at a price both sides agreed to. A cart is a wish. An order is a contract.

Adding the same thing twice

@@unique([cartId, variantId])

That constraint is doing real work. Without it, tapping "Add" twice gives you two lines of one kilogram rather than one line of two, and the cart page grows a "merge duplicate lines" function that nobody wants to write.

With it, the add becomes an upsert:

const wanted = (existing?.quantity ?? 0) + quantity;

await this.prisma.cartItem.upsert({
  where: { cartId_variantId: { cartId: cart.id, variantId } },
  create: { cartId: cart.id, variantId, quantity },
  update: { quantity: wanted },
});

And there is a cap, MAX_CART_QUANTITY, which is 50. Not because the database minds, but because a kirana shop that receives an order for 300 kg of onions should get a phone call, not a delivery slot.

The login merge

Now the moment everything has been building to. Somebody with three things in an anonymous cart clicks "Checkout" and is asked to sign in. They have an account, with two things in it from last week.

Four things could happen, and three of them are wrong:

  1. Keep the account cart, discard the anonymous one. You just deleted what they were about to buy. This is the worst one, and it is the most common.
  2. Keep the anonymous cart, discard the account one. Quieter, still a loss.
  3. Show both and make them choose. A dialogue box at the till.
  4. Merge them. Add the quantities, cap at the limit and at stock.

Merge:

for (const item of anonymous.items) {
  const existing = await this.prisma.cartItem.findUnique({
    where: { cartId_variantId: { cartId: target.id, variantId: item.variantId } },
    select: { quantity: true },
  });

  const combined = (existing?.quantity ?? 0) + item.quantity;
  const capped = Math.min(combined, MAX_CART_QUANTITY, item.variant.stock);

  if (capped <= 0) continue;
  // …upsert `capped`…
}

await this.prisma.cart.delete({ where: { id: anonymous.id } });

Then the anonymous cart is deleted, which cascades to its items and frees the unique sessionId so the token cannot be used again. And the cookie is cleared, because the customer is no longer anonymous.

A cart the customer then trims is a small annoyance. A cart that silently lost three items is a lost sale and, worse, a customer who now does not trust the shop to remember anything.

Check your work

Why the cart is in the database: it survives a device change, the shop can see it, and prices and stock are read from where they are enforced.

Why not a cookie: 4 KB, and it rides along on every request including images.

Why a random token rather than the cart id: the cookie is a bearer credential, and ids leak into logs, URLs and error reports.

Why GET /cart must not create a row: every crawler would leave an empty cart behind, which makes abandonment figures meaningless.

Why the line total is computed, not stored: a stored total can disagree with the price printed beside it.

Why @@unique([cartId, variantId]): it makes "add the same thing twice" increase the line rather than create a second one, which is the only sane behaviour.

Why the login merge adds rather than replaces: either replacement silently deletes something the customer chose.

Why the anonymous cart is deleted after merging: it cascades the items away and frees the unique token so it cannot be replayed.

Practice

  1. Add something to the cart signed out, then look at the carts table. Confirm there is one row with a sessionId and no userId.
  2. Delete the cookie in developer tools and reload. Confirm the cart looks empty, and that the row is still in the database.
  3. Call GET /cart with no cookie twenty times. Confirm SELECT count(*) FROM carts has not changed.
  4. Add the same variant twice. Confirm you get one line with quantity 2, not two lines.
  5. Remove the @@unique([cartId, variantId]) constraint, migrate, and do it again. Watch the duplicate line appear.
  6. Try to add 60 of one item. Read the error message and decide whether a customer would understand it.
  7. Build an anonymous cart with dal in it, sign in to an account that already has atta, and confirm you end up with both.
  8. Put dal in both carts and confirm the quantities are added rather than one overwriting the other.
  9. Sign in and check that the kirana_cart cookie has been cleared and the anonymous row is gone.
  10. Change a variant's price directly in the database while something is in a cart. Reload the cart and confirm the new price shows.

Next: the interface that sits on top of all this.

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