Build an Online Shop with Next.js #3: Shopping Cart

9 min read

Last time we finished the two catalog pages. Everything up to that point was a Server Component. In this post, a Client Component appears for the first time — because it’s time to build the shopping cart, where users add products, change quantities, and expect the state to survive a refresh.

As is standard for shops without login, the cart lives locally only. The server knows nothing about the user’s cart until just before checkout. localStorage serves as the persistence layer.

Where does the client boundary go? #

First, let’s draw the line clearly.

AreaWhereWhy
Product dataServerStatic JSON. Reading it on the server is natural and fast
Catalog pagesServerNo user interaction. SEO-friendly
Cart stateClient (Context)Per-user, changes quickly, must survive refresh
Cart pageClientRenders the state directly
“Add to cart” buttonClientA click mutates the cart
Header quantity badgeClientReacts instantly to cart changes

The core principle: server by default, client only for the small units that need interaction. The header itself stays a Server Component; only the quantity badge inside it gets split out as a Client Component.

Designing CartProvider #

Decide on the shape of the cart’s data first.

Cart item shape
{
  slug: 'minimal-tee-black',
  name: 'Minimal Tee (Black)',
  price: 24000,
  image: '/images/products/minimal-tee-black.jpg',
  quantity: 2,
}

There’s no need to store the whole product. Keep only the minimum the checkout needs in the cart. If we ignore the possibility of the price changing mid-session (and at the learning stage, we do), storing the price right in the cart is the simplest approach.

src/app/components/CartProvider.js:

src/app/components/CartProvider.js
'use client';

import { createContext, useContext, useEffect, useState } from 'react';

const CartContext = createContext(null);
const STORAGE_KEY = 'cart-v1';

export function CartProvider({ children }) {
  const [items, setItems] = useState([]);
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (raw) setItems(JSON.parse(raw));
    } catch {
      // Ignore corrupted JSON and start with an empty cart
    }
    setHydrated(true);
  }, []);

  useEffect(() => {
    if (!hydrated) return;
    localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
  }, [items, hydrated]);

  function addItem(product, quantity = 1) {
    setItems((prev) => {
      const found = prev.find((it) => it.slug === product.slug);
      if (found) {
        return prev.map((it) =>
          it.slug === product.slug ? { ...it, quantity: it.quantity + quantity } : it,
        );
      }
      return [
        ...prev,
        {
          slug: product.slug,
          name: product.name,
          price: product.price,
          image: product.image,
          quantity,
        },
      ];
    });
  }

  function updateQuantity(slug, quantity) {
    if (quantity <= 0) return removeItem(slug);
    setItems((prev) => prev.map((it) => (it.slug === slug ? { ...it, quantity } : it)));
  }

  function removeItem(slug) {
    setItems((prev) => prev.filter((it) => it.slug !== slug));
  }

  function clear() {
    setItems([]);
  }

  const totalQuantity = items.reduce((sum, it) => sum + it.quantity, 0);
  const totalPrice = items.reduce((sum, it) => sum + it.price * it.quantity, 0);

  const value = {
    items,
    hydrated,
    totalQuantity,
    totalPrice,
    addItem,
    updateQuantity,
    removeItem,
    clear,
  };

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

export function useCart() {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCart must be called inside a CartProvider');
  return ctx;
}

The heart of this component is the pair of useEffects.

  • First effect (once on mount): loads the saved cart from localStorage, then sets hydrated to true.
  • Second effect (on every items change): writes the cart back to localStorage — but only while hydrated is true.

The hydrated guard exists to prevent an accident where the second effect overwrites localStorage with items: [] before the first effect has had a chance to load anything. Even though both effects run on mount, the flag makes the intent explicit in the code.

If useCart is called outside the Provider, it gets a null context — and left alone, that produces a confusing error at some later call like ctx.items. Throwing explicitly makes debugging much easier.

Hydration-safe pattern #

At server render time there is no localStorage, so the cart is []. Once the client mounts, the cart loads from localStorage and the screen updates. That difference can show up as a flicker, which may bother you.

There are two ways to handle it.

  1. For small elements like the quantity badge, simply don’t render until hydrated is true — the badge is briefly absent on first paint, then the correct number appears.
  2. For screens where the cart is the main content, like /cart, show a “Loading” placeholder during hydration.

The latter feels smoother to users. We’ll apply both in the actual code below.

Wiring CartProvider into RootLayout #

Cart state is referenced by nearly every page, so the Provider goes in RootLayout.

src/app/layout.js (changed part):

src/app/layout.js
import Link from 'next/link';
import { CartProvider } from './components/CartProvider';
import CartBadge from './components/CartBadge';
import './globals.css';

export const metadata = {
  title: { default: 'My Shop', template: '%s | My Shop' },
  description: 'An online shop built for learning',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <CartProvider>
          <header className="border-b">
            <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
              <Link href="/" className="font-bold">My Shop</Link>
              <nav className="flex items-center gap-4 text-sm">
                <Link href="/products">Products</Link>
                <Link href="/cart" className="relative">
                  Cart
                  <CartBadge />
                </Link>
              </nav>
            </div>
          </header>
          {children}
        </CartProvider>
      </body>
    </html>
  );
}

Even though the 'use client'-marked CartProvider sits inside RootLayout, everything outside it (like the header markup) still renders on the server. CartProvider merely marks the start of a client boundary — its children don’t automatically become Client Components. If a child is a Server Component, it’s rendered on the server and passed down as props. The header’s static markup is an example.

CartBadge — the quantity badge #

src/app/components/CartBadge.js:

src/app/components/CartBadge.js
'use client';

import { useCart } from './CartProvider';

export default function CartBadge() {
  const { totalQuantity, hydrated } = useCart();

  if (!hydrated || totalQuantity === 0) return null;

  return (
    <span className="absolute -right-3 -top-2 rounded-full bg-red-500 px-1.5 py-0.5 text-xs text-white">
      {totalQuantity}
    </span>
  );
}

While hydrated is false, no badge is rendered. The server render and the first client render both agree on “no badge,” so there’s no hydration mismatch.

AddToCartButton — the “Add to cart” button #

Time to bring the detail page’s inert button to life. The page itself stays a Server Component; only the button is split out as a Client Component.

src/app/components/AddToCartButton.js:

src/app/components/AddToCartButton.js
'use client';

import { useState } from 'react';
import { useCart } from './CartProvider';

export default function AddToCartButton({ product }) {
  const { addItem } = useCart();
  const [justAdded, setJustAdded] = useState(false);

  function handleClick() {
    addItem(product, 1);
    setJustAdded(true);
    setTimeout(() => setJustAdded(false), 1500);
  }

  return (
    <button
      type="button"
      onClick={handleClick}
      className="rounded-md bg-black px-4 py-3 text-white hover:bg-gray-800"
    >
      {justAdded ? 'Added!' : 'Add to cart'}
    </button>
  );
}

A small detail: the button shows “Added!” feedback for 1.5 seconds. Not forcing the user to the cart page keeps the flow smooth — they can keep browsing other products.

In the detail page (src/app/products/[slug]/page.js), replace the disabled button with this.

src/app/products/[slug]/page.js — changed part
import AddToCartButton from '../../components/AddToCartButton';

// ...

{isOutOfStock ? (
  <button type="button" disabled className="rounded-md bg-gray-300 px-4 py-3 text-white">
    Sold out
  </button>
) : (
  <AddToCartButton
    product={{
      slug: product.slug,
      name: product.name,
      price: product.price,
      image: product.image,
    }}
  />
)}

We pass only the fields the cart needs, not the whole product. Props crossing the Server → Client boundary must be serializable, so it’s a good habit not to ship unnecessary fields across.

/cart — the cart page #

src/app/cart/page.js
'use client';

import Link from 'next/link';
import Image from 'next/image';
import { useCart } from '../components/CartProvider';
import { formatPrice } from '../lib/format';

export default function CartPage() {
  const { items, hydrated, totalPrice, updateQuantity, removeItem } = useCart();

  if (!hydrated) {
    return (
      <main className="mx-auto max-w-3xl px-4 py-8">
        <h1 className="text-2xl font-bold">Cart</h1>
        <p className="mt-4 text-gray-500">Loading</p>
      </main>
    );
  }

  if (items.length === 0) {
    return (
      <main className="mx-auto max-w-3xl px-4 py-8">
        <h1 className="text-2xl font-bold">Cart</h1>
        <p className="mt-4 text-gray-500">Your cart is empty.</p>
        <Link
          href="/products"
          className="mt-4 inline-block rounded-md border px-4 py-2 text-sm"
        >
          Browse products
        </Link>
      </main>
    );
  }

  return (
    <main className="mx-auto max-w-3xl px-4 py-8">
      <h1 className="text-2xl font-bold">Cart</h1>

      <ul className="mt-6 divide-y border-y">
        {items.map((it) => (
          <li key={it.slug} className="flex items-center gap-4 py-4">
            <div className="relative h-20 w-20 shrink-0 overflow-hidden rounded bg-gray-100">
              <Image src={it.image} alt={it.name} fill sizes="80px" className="object-cover" />
            </div>
            <div className="flex-1">
              <Link href={`/products/${it.slug}`} className="font-medium hover:underline">
                {it.name}
              </Link>
              <div className="mt-1 text-sm text-gray-600">{formatPrice(it.price)}</div>
            </div>
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => updateQuantity(it.slug, it.quantity - 1)}
                className="h-8 w-8 rounded border"
                aria-label="Decrease quantity"
              >
                
              </button>
              <span className="w-8 text-center">{it.quantity}</span>
              <button
                type="button"
                onClick={() => updateQuantity(it.slug, it.quantity + 1)}
                className="h-8 w-8 rounded border"
                aria-label="Increase quantity"
              >
                +
              </button>
            </div>
            <div className="w-24 text-right font-semibold">
              {formatPrice(it.price * it.quantity)}
            </div>
            <button
              type="button"
              onClick={() => removeItem(it.slug)}
              className="text-sm text-gray-500 hover:text-red-500"
            >
              Remove
            </button>
          </li>
        ))}
      </ul>

      <div className="mt-6 flex items-center justify-between">
        <div className="text-lg">Total</div>
        <div className="text-2xl font-bold">{formatPrice(totalPrice)}</div>
      </div>

      <Link
        href="/checkout"
        className="mt-6 inline-block w-full rounded-md bg-black px-4 py-3 text-center text-white"
      >
        Proceed to checkout
      </Link>
    </main>
  );
}

A few details:

  • “Loading” during hydration — prevents the flicker where the cart looks empty on the first paint while hydrated is still false.
  • Auto-remove when quantity hits 0 — inside updateQuantity, quantity <= 0 delegates to removeItem. The natural behavior when the user presses “−” all the way down.
  • aria-label — the “+” / “−” buttons are visually obvious but ambiguous to screen readers.
  • The checkout link — the entrance to the next post.

Verify #

  • http://localhost:3000/products/minimal-tee-black → click “Add to cart” → red badge 1 in the header
  • Add the same product twice more → badge 3, merged into quantity 3 on /cart
  • Add a different product → two rows on /cart
  • Refresh → cart intact
  • Change quantity with “+” / “−” → total updates instantly, localStorage too
  • Reduce quantity to 0 or click “Remove” → item removed
  • Remove the last item → empty-cart message + browse link

Open the Application tab in your browser DevTools → Local Storage, find the cart-v1 key, and you’ll see the cart state stored as JSON.

Wrap-up #

This post tackled client state and persistence for the first time.

  • Drew a clear Server / Client boundary (switching to client at just one point — the Provider)
  • Combined the Context API and localStorage persistence in CartProvider
  • Applied a hydration-safe pattern (the hydrated flag)
  • The quantity badge, the add-to-cart button, and the cart page all share the same state
  • The cart survives a refresh

Everything we’ve built so far has been read-centric. Even the cart is just client state — the server still doesn’t know it exists. In the next post, we’ll create and send something to the server for the first time: an order. In “Build an Online Shop with Next.js #4: Checkout and Payment Simulation,” we’ll build the order form, create orders with Server Actions, and wire up a fake payment gateway to handle the success/failure branches.

X