E-Commerce
Rules
- Cart state: Zustand store with
localStorage persistence — survives page refresh and tab close
- Product variants: model as separate SKUs with shared parent product — each variant has its own price, inventory, image
- Checkout: Stripe Checkout Sessions (server-side) — never send prices from the client
- Inventory: check stock server-side before creating checkout session — race condition protection with database transactions
- Order lifecycle:
pending → paid → shipped → delivered — update via webhook events, not client actions
- Product listing: server-side filtering/sorting/pagination — URL search params for shareable filtered views
- Prices: store in cents (integers) — display with
Intl.NumberFormat and currency code
- Cart operations: add, remove, update quantity, clear — validate stock on every cart action
Patterns
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface CartItem { productId: string; variantId: string; quantity: number; price: number; }
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (variantId: string) => void;
updateQuantity: (variantId: string, quantity: number) => void;
clear: () => void;
total: () => number;
}
export const useCart = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find((i) => i.variantId === item.variantId);
if (existing) {
return { items: state.items.map((i) =>
i.variantId === item.variantId ? { ...i, quantity: i.quantity + item.quantity } : i
)};
}
return { items: [...state.items, item] };
}),
removeItem: (variantId) => set((s) => ({ items: s.items.filter((i) => i.variantId !== variantId) })),
updateQuantity: (variantId, quantity) => set((s) => ({
items: quantity <= 0
? s.items.filter((i) => i.variantId !== variantId)
: s.items.map((i) => (i.variantId === variantId ? { ...i, quantity } : i)),
})),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}),
{ name: "cart-storage" }
)
);
Avoid
- Sending prices from the client to create checkout — always resolve prices server-side from your database
- Storing cart only in React state — use persisted Zustand or localStorage so cart survives refresh
- Deducting inventory on "add to cart" — only reserve stock at checkout, release on session expiry
- Updating order status from the client — use Stripe webhooks for
checkout.session.completed
- Floating point for money — store as integers (cents), format for display only