E-Commerce in React: The Storefront With No Shopping Cart
This storefront sells 113 products with zero cart code: one Server Action resolves a fixed-tier checkout, and a slot-based entitlement engine replaces the order.
Search "e-commerce react" and the top results are GitHub repos and tutorials that all start the same way: a CartContext, a useState<CartItem[]>, and an add-to-cart handler that updates it client-side. This storefront sells 113 products through a real, live checkout, and none of its code looks like that — because it doesn't have a cart at all.
The short answer
React has no opinion on commerce; every ecommerce tutorial's cart is a library choice, not a framework requirement. This site sells tiered access (Free/T1/T2/T3) through one Server Action that resolves a hosted checkout URL from the verified session — no client-side cart state, no CartContext, no "add to cart" button anywhere in the codebase.
What the tutorials all build first
The top results for this query — a Snipcart walkthrough, a Syncfusion "digital products" series, and the GitHub topics react-ecommerce and ecommerce-react — agree on a shape before they agree on anything else: a cart. Line items, a quantity per item, a running subtotal, usually Context plus localStorage so the cart survives a refresh, sometimes Redux if the tutorial is older. It's the correct default for the thing they're building — a multi-item basket a shopper fills before paying once at the end.
It's also not the only shape ecommerce takes, and this codebase is the counter-example: a real, production storefront that sells through React/Next.js with zero lines of cart code.
What this storefront actually sells: tiers, not a basket
src/data/catalog.ts lists 113 template products, each with its own product page, screenshots and "Buy now" button. But the buyer doesn't add products to a basket — they buy access. Pricing is four tiers (Free, T1, T2, T3), each unlocking a different slice of the catalog, and BuyButton resolves a single checkout URL for whichever tier the visitor clicks:
// src/components/molecules/BuyButton.tsx (trimmed)
export default function BuyButton({ tier, variant, configured }: {
tier: Tier;
variant: ButtonVariant;
configured: boolean;
}) {
const [checkoutUrl, setCheckoutUrl] = useState<string | null>(null);
const [owned, setOwned] = useState(false);
useEffect(() => {
if (!configured) return;
void (async () => {
if (!hasAuthCookie()) return; // signed out — nothing to resolve
const supabase = await loadSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const ownedTiers = await getOwnedTiers();
if (ownedTiers.some((t) => tierCoversTier(t, tier))) {
setOwned(true);
return;
}
const result = await getCheckoutUrl(tier);
if (result.ok && result.url) setCheckoutUrl(result.url);
})();
}, [tier, configured]);
// ...renders Sign in / Buy now / Owned depending on the state above
}
There is no array of line items anywhere in this component, no quantity, no subtotal — tier is the only input, and it's a prop, not state the user builds up by clicking around the catalog.
The checkout: one Server Action, no client-trusted price
getCheckoutUrl is the whole "add to cart and checkout" flow, collapsed into a single Server Action that never lets the client name its own price:
// src/lib/actions/checkout.ts (trimmed)
"use server";
export async function getCheckoutUrl(tier: Tier): Promise<CheckoutUrlResult> {
const variantUuid = variantUuidForTier(tier);
if (!variantUuid || !variantIdForTier(tier)) {
return { ok: false, reason: "not_configured" };
}
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
const userId = data?.claims?.sub;
const email = data?.claims?.email;
if (!userId || !email) return { ok: false, reason: "unauthenticated" };
return { ok: true, url: buildCheckoutUrl(variantUuid, userId, email) };
}
tier is a fixed enum ("t1" | "t2" | "t3"), so the client can request one of three pre-priced LemonSqueezy variants — never a number. The buyer's id and email come from getClaims(), a server-verified session, not from a request body a client controls. A typical React-ecommerce tutorial's cart total is a reduce() over client state; this checkout's "total" is whichever of three fixed variant UUIDs the server agrees to hand back.
The entitlement engine: slots, not line items
Once a purchase completes, what a buyer owns isn't a list of items either — it's a set of slots, each one a pure-function check:
// src/lib/entitlements.ts (trimmed)
export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
if (slot.status !== "active") return false;
switch (slot.kind) {
case "all_access":
return true;
case "all_templates":
return target.framework !== "backend";
case "template_single":
return slot.productSlug === target.productSlug &&
target.framework !== "backend";
}
}
A React ecommerce tutorial's post-purchase state is usually "this cart is now an order with N items." This site's post-purchase state is "does any owned slot cover this download" — a boolean function over three slot kinds, run fresh on every /api/download request rather than stored as a snapshot of what was "in the cart" at checkout time.
What happens after checkout: a signed webhook, not a redirect page
A React ecommerce tutorial's "order confirmation" is usually a client route that reads a session id from the URL and renders a receipt. This storefront never trusts that redirect at all — the order only becomes real when LemonSqueezy's webhook arrives, verified server-side:
// src/app/api/webhooks/lemonsqueezy/route.ts (trimmed)
export const runtime = "nodejs"; // node:crypto for the HMAC check
export async function POST(req: Request) {
const raw = await req.text(); // never req.json() — HMAC is over the exact bytes signed
const signature = req.headers.get("X-Signature");
const result = await processWebhook(raw, signature, {
db, secret, storeId,
onUnattachedOrder: ({ lsOrderId, email, tier }) => {
console.error("lemonsqueezy webhook ALERT: unattached order", { lsOrderId, email, tier });
},
sendPurchaseWelcome: (email, tier) => after(() => sendPurchaseWelcome(email, tier)),
});
return new Response(result.body, { status: result.status });
}
Two details a cart-based tutorial rarely has to think about: the body is read with req.text(), not req.json(), because re-serializing a parsed object can differ byte-for-byte from what LemonSqueezy actually signed — parse first and the signature check silently fails against your own re-encoding. And the welcome email is scheduled through after() rather than fired directly, because an unawaited promise on Vercel has no guaranteed lifetime once the response is sent; without it, the email can be dropped mid-flight and nothing in a client-rendered "thank you" page would ever reveal that.
The comparison
| Typical React ecommerce tutorial | This storefront | |
|---|---|---|
| Cart state | Context + useState<CartItem[]>, often localStorage | None — no cart exists |
| What the buyer picks | Any number of products, any quantities | One of 4 fixed tiers |
| Checkout total | Computed client-side from cart state | One of 3 fixed LemonSqueezy variant UUIDs, server-resolved |
| Post-purchase record | An order with line items | A slot (template_single / all_templates / all_access) |
| Access check | Usually skipped in tutorials, or a flag on the user row | A pure function (slotCovers) re-run per download request |
| Where "add to cart" lives | A client component dispatching to cart state | Nowhere — there's no such action |
| Order confirmation | A client route reading a redirect param | A signed server-to-server webhook, verified over the raw request body |
When you actually do need a cart
Not every commerce shape is tier-based. Several of this catalog's own "shop" category templates — built for a buyer who wants to sell many products at varying prices — do need cart state, and Shopping Cart in Next.js 16 covers that architecture: a cookie for anonymous carts, a database row once a shopper signs in, and why Context plus localStorage is the worst of the three options for a cart that has to survive a device switch. The difference is what's being sold: a catalog of individually-priced, multi-quantity products needs a basket. A fixed set of access tiers, bought once each, doesn't — and building the basket anyway is the tutorial default this storefront's own checkout deliberately skips.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Checkout button flashes "Sign in" then "Buy now" on load | Client renders before the session/ownership check resolves | Render a neutral loading state (disabled button) until the effect settles, as BuyButton does |
| A buyer can re-purchase a tier they already own | No check against existing paid orders before opening checkout | Query owned tiers server-side (getOwnedTiers) and compare with a rank function like tierCoversTier, not a client flag |
| Checkout opens with the wrong price | Client sent a price or product id the server trusted | Resolve the price/variant server-side from a fixed enum (tier), never accept an amount from the client |
| Entitlement check passes for a tier the buyer doesn't own | Slot check reads cached/stale purchase state | Re-run authorizeDownload per request against current slot rows, don't snapshot access at login |
| Cart state survives after a session logs out | Cart lived in localStorage with no ownership scoping | If you do need a cart, key it to the session (cookie or DB row per user), not an unscoped browser store |
Frequently asked questions
Do I need Redux or Context to build ecommerce in React? No — state management is a choice for the commerce shape you're building, not a requirement of React itself. A multi-item basket benefits from centralized state; a fixed-tier or single-item checkout, like this storefront's, needs none.
How does this site prevent a buyer from picking their own price?
The client only ever sends a tier value from a fixed three-item enum. The server resolves that to a pre-configured LemonSqueezy variant UUID — there's no code path where a client-supplied number becomes the checkout amount.
What replaces "the cart" if there isn't one?
An entitlement slot (src/lib/entitlements.ts), a pure-function record of what a buyer owns, checked fresh against every download request rather than computed once at checkout and stored.
Is a cart ever the right choice for a React storefront? Yes — for a catalog of variably-priced, multi-quantity products. See Shopping Cart in Next.js 16 for that architecture, used by this catalog's own shop-category templates.
Templates in this post
ASoc Guard, ASoc Haven and ASoc Hearth are Next.js + Tailwind landing page templates, sold through the tiered checkout described above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
