Blog · · 12 min

Vibe coding a store: what breaks at checkout

Six things break between the code running and somebody paying: the cart forgets itself on reload, shipping appears at the last step, a rejected field cannot be corrected, the pay button submits twice, there is no order summary, and the VAT is wrong. Each one has a small fix and none of them shows up in local testing.

By

The store runs. Nobody pays. The gap is almost always the same six things, and none of them look like bugs while you are testing.

The cart empties itself when the page reloads. The shipping cost appears at the last step. A rejected field cannot be corrected. The pay button fires twice on a slow connection. There is no summary of what is being bought at the moment of paying. The tax is wrong or missing. Every one of these passes a happy-path test on your own laptop, because on your laptop you never reload, the network never stalls, and you type a valid postcode from memory.

What actually breaks?

SymptomWhere it happensWhat it costsThe fix in one line
Cart is empty after a reloadAny state kept only in React memoryThe whole sessionPersist to localStorage, rehydrate in an effect
Cart wiped by the app itselfRehydration racing the first saveSame, but harder to reproduceDo not save until the load has finished
Shipping revealed at the last stepCost computed after the address formThe buyer feels ambushed and leavesShow it before the buyer is bound, from the cart
Rejected field cannot be fixedError text with no link to the inputTotal block, no recoveryaria-invalid, aria-describedby, focus the field
Double charge or duplicate orderSlow network, impatient second clickA refund and a complaintDisable while pending, idempotency key server side
No summary at the payment stepWent straight from cart to card formHesitation at the worst momentLine items, shipping and total, visible on the page
Wrong or missing VATPrices stored excluding tax, consumer-facingA legal problem, not a UX oneConsumer prices displayed inclusive

Why does the cart empty itself on reload?

Because the cart lives in a useState and a reload throws the whole tree away. The buyer opens a product in a new tab, comes back, and the cart is gone. They rarely rebuild it.

The fix is small, and the trap is inside the fix:

// cart-storage.ts
const KEY = 'cart:v2' // version it: an old shape will crash JSON consumers

export type CartLine = {
  variantId: string
  quantity: number
  priceCents: number
}

function isLine(value: unknown): value is CartLine {
  if (typeof value !== 'object' || value === null) return false
  const l = value as Partial<CartLine>
  return (
    typeof l.variantId === 'string' &&
    Number.isInteger(l.quantity) &&
    (l.quantity ?? 0) > 0 &&
    Number.isInteger(l.priceCents)
  )
}

export function loadCart(): CartLine[] {
  if (typeof window === 'undefined') return [] // server render: always empty
  try {
    const raw = window.localStorage.getItem(KEY)
    if (!raw) return []
    const parsed: unknown = JSON.parse(raw)
    return Array.isArray(parsed) ? parsed.filter(isLine) : []
  } catch {
    return [] // private mode, quota exceeded, corrupted JSON
  }
}

export function saveCart(lines: CartLine[]): void {
  try {
    window.localStorage.setItem(KEY, JSON.stringify(lines))
  } catch {
    // storage disabled: the cart still works for this session
  }
}

Now the trap. The obvious hook wipes the cart on every page load:

export function useCart() {
  const [lines, setLines] = useState<CartLine[]>([])
  const [loaded, setLoaded] = useState(false)

  useEffect(() => {
    setLines(loadCart())
    setLoaded(true)
  }, [])

  useEffect(() => {
    if (!loaded) return // without this line, the empty initial state
    saveCart(lines) //    overwrites the stored cart on first render
  }, [lines, loaded])

  return { lines, setLines, loaded }
}

Remove the if (!loaded) return and the save effect runs once with the initial [], before the load effect has put anything back. The cart is destroyed by the code that was meant to preserve it. It reproduces every single time and is invisible if you only ever test by clicking add-to-cart and going straight to checkout.

Two more details worth taking now. Never read storage during render — the server output and the first client render must match, or React throws a hydration error and the page flashes. And never trust the stored price: revalidate every priceCents against the server before charging, because localStorage is a text field the buyer can edit.

![A till receipt torn across the middle on scratched black steel under hard light, the upper half printed TOTAL in bold and the lower half, still curled in the printer, printed SHIPPING.](/blog/ticket-caisse-dechire.webp "The half of the receipt the buyer sees, and the half that arrives at the last step. Shipping discovered after the address form is the most expensive line on the page.")

Why does the pay button charge twice?

Because the request took four seconds on a phone on a train, nothing on screen changed, and the buyer clicked again. This is not a rare edge case. It is the normal behaviour of a human facing an unresponsive button.

Two guards, and you want both. Client side, refuse to fire while a request is in flight:

export function PayButton({ totalLabel }: { totalLabel: string }) {
  const [pending, setPending] = useState(false)

  async function pay() {
    if (pending) return
    setPending(true)
    try {
      const res = await fetch('/api/checkout', { method: 'POST' })
      if (!res.ok) throw new Error('checkout failed')
      const { url } = (await res.json()) as { url: string }
      window.location.href = url
      // deliberately not clearing `pending`: the redirect is in flight
    } catch {
      setPending(false) // only on failure, so the buyer can retry
    }
  }

  return (
    <button onClick={pay} disabled={pending} aria-busy={pending}>
      {pending ? 'Taking you to payment…' : `Pay ${totalLabel}`}
    </button>
  )
}

The comment on the missing setPending(false) matters. Resetting it in a finally re-enables the button during the redirect, which is precisely the window where a second click lands.

Server side, make a repeated request return the same session rather than a new one:

// app/api/checkout/route.ts
const inFlight = new Map<string, Promise<{ url: string }>>()

export async function POST(req: Request) {
  const { cartToken } = (await req.json()) as { cartToken: string }
  const key = `checkout:${cartToken}`

  const existing = inFlight.get(key)
  if (existing) return Response.json(await existing) // same session, not a second one

  const job = createCheckoutSession(cartToken, { idempotencyKey: key })
  inFlight.set(key, job)
  job.finally(() => setTimeout(() => inFlight.delete(key), 60_000))

  return Response.json(await job)
}

Be honest about what that map is: a per-process guard that covers the double click and nothing else. It does not survive a restart and it does not span instances. The real protection is the idempotencyKey you pass to the payment provider, which every major one supports and which is the only mechanism that holds across processes.

Where should the shipping cost appear?

Before the buyer is committed, not after the address form.

The mechanical version: compute a shipping figure from the cart alone — a flat rate, a rate by weight band, a rate by destination once the buyer has chosen a country — and show it in the cart, next to the subtotal. If you genuinely cannot know it yet, say what determines it: Shipping from $4.90, calculated at the next step.

In the EU this is not a preference. The Consumer Rights Directive requires the total price, inclusive of taxes and delivery charges, before the consumer is bound by the contract; where the charge cannot reasonably be calculated in advance, you must say that it will be payable. Nothing about that is unusual, and the interface consequence is simply that the total is not allowed to be a surprise.

If you are showing a free-shipping threshold instead, the number has to be the amount still missing in currency rather than a percentage, and it has to be set against your margin — the arithmetic is here.

Why can the buyer not fix the field you rejected?

Three causes, all of them common in generated forms.

The error is not attached to the input. A red sentence at the top of the page, or a toast that disappears, and no programmatic link. The buyer cannot tell which field, and a screen reader announces nothing at all.

<input
  id="postcode"
  name="postal-code"
  autoComplete="postal-code"
  inputMode="text"
  aria-invalid={!!error}
  aria-describedby={error ? 'postcode-error' : undefined}
/>
{error && <p id="postcode-error">{error}</p>}

The validation is wrong. A postcode regex written for one country rejects a valid Eircode, a Canadian K1A 0B1, or a UK SW1A 1AA with its space. A phone field rejects the leading +. There is no recovery from a rule that is simply false, and the buyer has no way to know it is the rule that is broken rather than their own address.

The field should not be required. A company name, a second address line, a birth date. Every mandatory field is a place the checkout can end. Ask for what you need to ship and to charge, and stop.

One more, small and worth doing: after a failed submit, move focus to the first invalid field. Otherwise the buyer is looking at a red border somewhere below the fold with the keyboard caret still at the top.

What about the summary and the VAT?

Two things, both at the moment of paying.

The summary. Line items, quantities, shipping, tax and total, on the same screen as the card fields. A page that goes straight from a cart to a payment form asks the buyer to remember what they are about to spend. They will scroll back to check, and some of them will not come back. The layout for this is a solved problem.

The tax. For consumer sales in the EU, prices shown must include VAT — that is what the price indication rules require, and it is not a formatting preference. This is the single most common thing to get wrong when the product data comes from an ecommerce API that stores net prices, because the number renders perfectly and is simply the wrong number.

While you are there: the order button has to say what it does. EU rules require the consumer to explicitly acknowledge that the order carries an obligation to pay, with the button labelled unambiguously — Order with obligation to pay, Buy now, Pay $58.00. A button reading Continue, Confirm or Complete at that step is not compliant, and the sanction is that the buyer is not bound by the contract at all. This is a description of the rules, not legal advice; check your own jurisdiction and your product category.

The ten-minute test before you take money

Run this on a real phone, on cellular data, not on your laptop.

  1. Add two items, reload the page. Cart intact.
  2. Add an item, open the product in a new tab, come back. Cart intact.
  3. Turn on network throttling. Click pay twice, fast. One order.
  4. Enter a valid postcode from a country you did not think about. Accepted.
  5. Submit the form with one field empty. Focus lands on that field, and the message sits next to it.
  6. Look at the payment step. Line items, shipping, tax and total, all visible without scrolling back.
  7. Check one product price against a calculator. Tax included, if you sell to consumers in the EU.
  8. Read the button. It says what it costs, or that it obliges you to pay.

Everything on that list is twenty minutes of work and it is the difference between a store that runs and a store that takes money. If you are still choosing between owning this code and renting a platform that already handles it, that trade-off is worked through here.

uxgen is an MCP that hands a coding agent commerce components as HTML the merchant keeps, from $19 a month. It has no paying customers yet and the site says so on every page. The cart mechanics are MIT and readable without installing anything: github.com/kinerette/uxgen-commerce-kit.

FAQ

Why does my cart empty when the page reloads?

Because the cart lives in component state and a reload discards it. Persist the lines to localStorage and rehydrate them in an effect. The subtle failure is in the fix: if the save effect runs before the load effect has finished, the empty initial state overwrites the stored cart, so guard the save with a loaded flag. Never read storage during render either, or the server and client markup disagree and React throws a hydration error.

How do I stop a checkout button from charging twice?

Two guards. On the client, ignore clicks while a request is in flight and keep the button disabled through the redirect rather than re-enabling it in a finally. On the server, send an idempotency key derived from the cart to the payment provider so a repeated request returns the existing session instead of creating a second one. An in-memory map is a useful extra guard for the double click but does not survive a restart or span instances.

When do I have to show shipping costs?

Before the buyer is bound by the contract, which in practice means in the cart rather than after the address form. The EU Consumer Rights Directive requires the total price including taxes and delivery charges up front; if the charge genuinely cannot be calculated in advance, you must say that it will be payable. Showing a from-price with the condition attached satisfies both the rule and the buyer.

What should the pay button say?

What it costs, or that it obliges the buyer to pay. EU rules require the consumer to explicitly acknowledge the payment obligation, with the button labelled unambiguously — Pay $58.00, Buy now, or Order with obligation to pay. Vague labels such as Continue, Confirm or Complete do not meet that requirement, and the consequence is that the consumer is not bound by the contract.

uxgen is a service of UXGen AI, LLC — 131 Continental Dr, Suite 305, Newark, DE 19713, United States.

© 2026 UXGen AI, LLC. All rights reserved.