Skip to main content
ASoc
Tutorial

How to Add Items to a Cart with HTML and JavaScript (Safely)

A 70-line cart with data attributes, one delegated click handler and localStorage, then the production version of the same pattern from a real storefront.

The ASoc Team7 min read

To add items to a cart with HTML and JavaScript, put the product data on each button as data-* attributes, listen for one delegated click on the page, and keep the cart as an array saved to localStorage. Always parse the stored value defensively. This post builds that in about 70 lines and shows the production version of the same idea from this repo.

The shape of a client-side cart

Every tutorial for this converges on the same four parts. What separates a toy from something you can ship is how the fourth part handles bad data.

PartJobToy versionWhat to do instead
MarkupSay what each button addsonclick="addToCart('Shoes', 49)"data-id, data-name, data-price attributes
Event wiringNotice the clickOne listener per buttonOne delegated listener on document
StateHold the itemsA global variable, lost on reloadAn array saved to localStorage
Reading it backRebuild state on loadJSON.parse(localStorage.getItem(k))Parse inside try, validate every entry

The last row is the one that crashes pages. JSON.parse(null) returns null, a corrupt string throws, and in Safari's private mode even touching localStorage can throw. A cart that loses its contents is annoying; a cart that stops the page rendering is a defect.

Step 1: the markup

<ul id="products">
  <li>
    <h3>Starter kit</h3>
    <p>$49</p>
    <button
      class="add-to-cart"
      data-id="starter-kit"
      data-name="Starter kit"
      data-price="49"
    >
      Add to cart
    </button>
  </li>
  <li>
    <h3>Pro kit</h3>
    <p>$99</p>
    <button
      class="add-to-cart"
      data-id="pro-kit"
      data-name="Pro kit"
      data-price="99"
    >
      Add to cart
    </button>
  </li>
</ul>

<h2>Cart (<span id="cart-count">0</span>)</h2>
<ul id="cart-items"></ul>
<p>Total: $<span id="cart-total">0</span></p>

Using attributes instead of inline onclick keeps the data next to the element, avoids quoting bugs, and lets one listener handle every button, including ones added later by script.

Step 2: read and write safely

const CART_KEY = "shop:cart:v1";

function readCart() {
  let parsed;
  try {
    parsed = JSON.parse(localStorage.getItem(CART_KEY) || "[]");
  } catch {
    return []; // corrupt JSON, or storage disabled
  }
  if (!Array.isArray(parsed)) return [];
  return parsed.filter(
    (line) =>
      typeof line.id === "string" &&
      typeof line.name === "string" &&
      Number.isFinite(line.price) &&
      Number.isInteger(line.qty) &&
      line.qty > 0,
  );
}

function writeCart(cart) {
  try {
    localStorage.setItem(CART_KEY, JSON.stringify(cart));
  } catch {
    // quota exceeded or storage disabled: keep going in memory
  }
}

The v1 in the key is cheap insurance. When you change the line shape later, bump it and old browsers start from an empty cart instead of feeding old data to new code.

Step 3: add, render, delegate

function addToCart(item) {
  const cart = readCart();
  const line = cart.find((l) => l.id === item.id);
  if (line) line.qty += 1;
  else cart.push({ ...item, qty: 1 });
  writeCart(cart);
  render();
}

function render() {
  const cart = readCart();
  const list = document.getElementById("cart-items");
  list.replaceChildren(
    ...cart.map((l) => {
      const li = document.createElement("li");
      li.textContent = `${l.name} x ${l.qty} ($${l.price * l.qty})`;
      return li;
    }),
  );
  document.getElementById("cart-count").textContent = String(
    cart.reduce((n, l) => n + l.qty, 0),
  );
  document.getElementById("cart-total").textContent = String(
    cart.reduce((sum, l) => sum + l.price * l.qty, 0),
  );
}

document.addEventListener("click", (e) => {
  const btn = e.target.closest(".add-to-cart");
  if (!btn) return;
  addToCart({
    id: btn.dataset.id,
    name: btn.dataset.name,
    price: Number(btn.dataset.price),
  });
});

render();

Two choices here are deliberate. textContent writes the name as text, so a product name containing <img onerror=...> cannot execute. And closest() makes the handler work when the click lands on an icon inside the button.

The production version of the same idea

This repo has no cart, and that is a decision. Templates are digital downloads, so there is nothing to put a quantity on, and checkout is a hosted LemonSqueezy page opened from BuyButton. What it does have is a "saved templates" list, and that is the same pattern at production strength: src/lib/wishlist.ts (a localStorage array), src/lib/useWishlist.ts (the React binding) and src/lib/__tests__/wishlist.test.ts (17 tests).

Compare its read path to Step 2:

// src/lib/wishlist.ts
export function parseWishlist(raw: string | null): WishlistEntry[] {
  if (!raw) return [];
  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return [];
  }
  if (!Array.isArray(parsed)) return [];

  const seen = new Set<string>();
  const entries: WishlistEntry[] = [];
  for (const item of parsed) {
    if (!isValidEntry(item)) continue;
    if (seen.has(item.slug)) continue;
    seen.add(item.slug);
    entries.push({ slug: item.slug, name: item.name, image: item.image });
    if (entries.length >= WISHLIST_MAX) break;
  }
  return entries;
}

It is the same try/catch/Array.isArray skeleton, plus four things the toy lacks:

Production detailWhyYour cart's equivalent
Rebuilds each entry from three known fieldsA future version or a tamperer cannot smuggle extra keys throughCopy id, name, price, qty explicitly
De-duplicates by slugTwo tabs can write the same itemMerge lines by id, as addToCart does
Caps the list at WISHLIST_MAX (200)localStorage is not a databaseCap line count and quantity
Validates image is root-relative (/images/...)The value flows into <img src>; a //host value would call a third partyNever trust a stored URL, price or id

That last point deserves a second look for a real shop. A price in localStorage is a price the buyer can edit in dev tools. The cart remembers what was chosen; the server must decide what it costs at checkout. Here the catalog is the price source and nothing in the wishlist grants an entitlement.

Keeping several views in sync

Step 3 calls render() by hand. Once a header badge and a cart page both show the count, that stops scaling. The storage event fires only in other tabs, so a same-tab change needs its own signal. The repo's subscription does both:

// src/lib/wishlist.ts
export function subscribeWishlist(listener: () => void): () => void {
  if (typeof window === "undefined") return () => {};
  const onStorage = (e: StorageEvent) => {
    if (e.key === null || e.key === WISHLIST_STORAGE_KEY) listener();
  };
  window.addEventListener(WISHLIST_CHANGED_EVENT, listener);
  window.addEventListener("storage", onStorage);
  return () => {
    window.removeEventListener(WISHLIST_CHANGED_EVENT, listener);
    window.removeEventListener("storage", onStorage);
  };
}

persist() dispatches WISHLIST_CHANGED_EVENT after every write. In plain JS, do the same: after writeCart, call window.dispatchEvent(new Event("cart:changed")) and let render listen for it plus storage.

If you are on React or Next.js

Do not copy the Step 3 pattern into a component with useState plus an effect. Reading localStorage during render makes the server HTML (empty) disagree with the first client render, and a snapshot function that returns a fresh array each call makes useSyncExternalStore loop forever. The repo handles both: a memoized snapshot keyed on the raw string, and a server snapshot that is always empty. The full story is in the localStorage hydration mismatch post. For a cart that lives on the server instead, see a shopping cart with Server Components.

Troubleshooting

SymptomCauseFix
Nothing happens on clickScript runs before the DOM existsPut the <script> at the end of <body> or use defer
Count resets on reloadYou stored the cart in a variable, not localStorageCall writeCart on every change
Unexpected token on loadA corrupt or old stored valueWrap JSON.parse in try, as in readCart
Total shows NaNdataset.price is a string or missingNumber(btn.dataset.price) and validate with Number.isFinite
Click on an icon inside the button does nothinge.target is the iconUse e.target.closest(".add-to-cart")
Works in Chrome, throws in Safari private modeStorage access throwsKeep reads and writes in try/catch
Second tab shows a stale cartstorage fires only in other tabs, and you never listenedAdd a storage listener and re-render

Frequently asked questions

Is localStorage safe for a cart? Safe for remembering choices, unsafe as a source of truth. Anyone can edit it. Store ids and quantities, then look up prices on the server at checkout.

How long does localStorage keep the cart? Until the user or the browser clears it; there is no expiry. Add a timestamp to your cart object if you want to expire it yourself. sessionStorage clears when the tab closes.

Should I use cookies instead? Only if the server must read the cart on each request. Cookies are sent with every request and are capped near 4 KB, so they suit a cart id, not the items.

When do I need a backend? When the cart must follow the user across devices, when stock or price changes matter, or when you take payment. Until then a client-side cart is a convenience layer.

Templates in this post

ASoc Brief (a designer résumé and portfolio site), ASoc Byte (an IT and startup studio site) and ASoc Canvas (a no-code page-builder site) are finished landing pages you can wire the buttons above into instead of starting from a blank file.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial8 min read

Building an HTML/CSS Navigation Bar (and the Bug That Hides It)

Six ingredients build an accessible nav bar — and this site's own mobile drawer shipped the pointer-events-none bug that leaves closed links keyboard-reachable.

Read more
Tutorial9 min read

HTML Nav: The Element, Not the Layout

A real production nav, from a 4-item desktop bar to a slide-out drawer — the aria-expanded toggle, the z-index stacking, and what a div-only navbar loses.

Read more