3 MIN READ

Implementing a Medusa Cart That Does Not Lose Items

Cart persistence, the guest-to-customer merge, inventory races and optimistic UI. The cart problems that only appear in production, and how to avoid them.

BY ANANYA IYERUPDATED
Illustration for “Implementing a Medusa Cart That Does Not Lose Items” — Storefront

Carts look trivial and are not. A cart survives browser restarts, spans anonymous and logged-in sessions, holds prices that change, and reserves inventory that other people are also buying. Every one of those is a place to lose an order.

Persistence

lib/data/cart.tsts
"use server"

import { cookies } from "next/headers"
import { sdk } from "@/lib/medusa"

const CART_COOKIE = "_medusa_cart_id"

export async function getCart() {
  const id = (await cookies()).get(CART_COOKIE)?.value
  if (!id) return null

  try {
    const { cart } = await sdk.store.cart.retrieve(id, {
      fields: "*items,*items.variant,*items.variant.product,*region,+items.total,*promotions",
    })
    // A completed cart is an order. Reusing it shows the customer a phantom cart.
    return cart?.completed_at ? null : cart
  } catch {
    return null
  }
}

export async function getOrCreateCart(regionId: string) {
  const existing = await getCart()
  if (existing) return existing

  const { cart } = await sdk.store.cart.create({ region_id: regionId })

  ;(await cookies()).set(CART_COOKIE, cart.id, {
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production",
    maxAge: 60 * 60 * 24 * 30,
    path: "/",
  })

  return cart
}

Three details that matter. The completed_at check prevents the phantom cart every implementation ships once. The fields projection fetches everything the cart drawer renders in one request — see the SDK guide. And the cookie is httpOnly, so it exists during server rendering and is invisible to third-party scripts.

Adding items

ts
export async function addToCart(variantId: string, quantity: number) {
  const cart = await getOrCreateCart(process.env.NEXT_PUBLIC_DEFAULT_REGION!)

  try {
    await sdk.store.cart.createLineItem(cart.id, {
      variant_id: variantId,
      quantity,
    })
  } catch (error: any) {
    if (error?.status === 400) {
      return { error: "Not enough stock available." }
    }
    throw error
  }

  revalidateTag("cart")
  return { success: true }
}

Medusa merges a duplicate variant into the existing line rather than adding a second row, so you do not need to check first.

Guest to customer

The failure mode: a guest adds three items, logs in, and the cart is empty. It is a real revenue leak and an easy fix — transfer the cart rather than creating a new one:

ts
export async function loginAndKeepCart(email: string, password: string) {
  const token = await sdk.auth.login("customer", "emailpass", { email, password })
  await setAuthCookie(token as string)

  const cart = await getCart()
  if (cart && !cart.customer_id) {
    await sdk.store.cart.transferCart(cart.id, {}, { authorization: `Bearer ${token}` })
  }

  revalidateTag("cart")
}

Do the same after registration. The rule: never let an authentication event discard a cart.

Inventory races

Stock is checked when an item is added and again when the cart completes. Between those two moments, someone else can buy the last unit.

Handle it at completion, not just at add:

ts
export async function completeCart(cartId: string) {
  try {
    const result = await sdk.store.cart.complete(cartId)
    if (result.type === "order") {
      ;(await cookies()).delete(CART_COOKIE)
      return { order: result.order }
    }
    return { error: "Checkout could not be completed." }
  } catch (error: any) {
    if (error?.status === 400) {
      // Almost always inventory that went while the customer was typing.
      return { error: "An item in your cart is no longer available.", refresh: true }
    }
    throw error
  }
}

Then re-render the cart with the offending line flagged. Do not silently drop it — a customer who reaches the confirmation page with fewer items than they chose will contact support.

Note the shape: complete returns either an order or a cart, so branch on result.type rather than assuming success.

Optimistic updates

Quantity changes should feel instant, but the server is the authority on price and stock:

tsx
"use client"

import { useOptimistic, startTransition } from "react"

export function QuantityStepper({ item, onChange }: { item: LineItem; onChange: (q: number) => Promise<void> }) {
  const [optimisticQty, setOptimisticQty] = useOptimistic(item.quantity)

  const update = (next: number) => {
    startTransition(async () => {
      setOptimisticQty(next)
      await onChange(next)   // server action; revalidation reconciles
    })
  }

  return (
    <div>
      <button onClick={() => update(Math.max(1, optimisticQty - 1))} aria-label="Decrease">−</button>
      <span aria-live="polite">{optimisticQty}</span>
      <button onClick={() => update(optimisticQty + 1)} aria-label="Increase">+</button>
    </div>
  )
}

Be optimistic about quantity, never about price or availability. Showing a total that the server then disagrees with is worse than a 200ms wait.

Abandoned carts

A scheduled job finds carts idle for an hour with an email captured and no completed_at, and emits an event a subscriber turns into a reminder. Store a flag on the cart so the same customer is not emailed twice — subscribers retry, and a duplicate abandoned-cart email is a memorable way to annoy someone.

Practical guidance

Fetch the cart once per request and pass it down. Three components each retrieving it is three round trips.

Revalidate by tag after every mutation. Cart UI that lags behind the cart is a support ticket.

Test the logged-in transfer explicitly. It is the highest-value cart path and the least exercised in development.

Handle region changes. Switching country may change prices and availability; refresh the cart rather than trusting cached line totals.

Promotion codes

Promotions attach to the cart and recalculate totals, and the error handling is what customers actually experience:

ts
export async function applyPromotion(code: string) {
  const cart = await getCart()
  if (!cart) return { error: "Your cart is empty." }

  try {
    await sdk.store.cart.update(cart.id, { promo_codes: [code] })
    revalidateTag("cart")
    return { success: true }
  } catch (error: any) {
    // Be specific: "invalid code" and "does not apply" are different problems.
    if (error?.status === 400) {
      return { error: "That code is not valid for the items in your cart." }
    }
    throw error
  }
}

Two behaviours worth deciding explicitly: whether codes stack, and what happens when a customer removes the item a promotion applied to. Medusa recalculates, so the discount disappears — surface that in the UI rather than letting the total change silently.

Stock display

Showing stock levels increases urgency and increases support tickets when it is wrong.

ApproachAccuracyLoad
No indicatorn/aNone
In stock / out of stockHighLow, cacheable
"Only N left" below a thresholdMust be liveHigher
Exact count alwaysMust be liveHighest

The middle option is the sweet spot: cache a boolean, and only fetch a live count when the item is below a low-stock threshold. Never render an exact count from a cache — see caching.

Remember that manage_inventory may be false on a variant, in which case there is no count and the item is always available. Treat that as in stock rather than as zero.


Cart and checkout are where headless builds leak revenue quietly. We audit them.

Frequently asked questions

How do I persist a Medusa cart between sessions?

Store the cart id in an httpOnly cookie with a 30-day expiry. It is available during server rendering, invisible to scripts on the page, and survives browser restarts — all things localStorage does not give you.

How do I transfer a guest cart when a customer logs in?

Call the cart transfer endpoint with the customer's token immediately after login or registration, while the guest cart id is still in the cookie. Skipping this is a common and expensive bug, because customers lose their basket at the moment they were about to buy.

What happens if inventory runs out while an item is in the cart?

Completion fails with a 400. Catch it, re-fetch the cart, and show which line is unavailable rather than a generic error. Stock is validated both when adding and at completion, so both paths need handling.

Should I use optimistic UI for cart updates?

For quantity changes, yes — the interaction should feel instant. Do not be optimistic about prices, totals or availability, since only the server can determine those and a corrected total reads as a bug.

How do I build abandoned cart recovery in Medusa?

A scheduled job queries carts that are idle, have an email and are not completed, then emits an event that a subscriber turns into a reminder. Record that the reminder was sent so retries do not email the same customer twice.

Why does my cart appear empty after checkout?

Because the completed cart is still referenced by the cookie. Delete the cart cookie when completion succeeds, and treat any cart with `completed_at` set as absent when retrieving.

[ Keep reading ]