Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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 tutorialThis storefront
Cart stateContext + useState<CartItem[]>, often localStorageNone — no cart exists
What the buyer picksAny number of products, any quantitiesOne of 4 fixed tiers
Checkout totalComputed client-side from cart stateOne of 3 fixed LemonSqueezy variant UUIDs, server-resolved
Post-purchase recordAn order with line itemsA slot (template_single / all_templates / all_access)
Access checkUsually skipped in tutorials, or a flag on the user rowA pure function (slotCovers) re-run per download request
Where "add to cart" livesA client component dispatching to cart stateNowhere — there's no such action
Order confirmationA client route reading a redirect paramA 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

SymptomCauseFix
Checkout button flashes "Sign in" then "Buy now" on loadClient renders before the session/ownership check resolvesRender a neutral loading state (disabled button) until the effect settles, as BuyButton does
A buyer can re-purchase a tier they already ownNo check against existing paid orders before opening checkoutQuery owned tiers server-side (getOwnedTiers) and compare with a rank function like tierCoversTier, not a client flag
Checkout opens with the wrong priceClient sent a price or product id the server trustedResolve 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 ownSlot check reads cached/stale purchase stateRe-run authorizeDownload per request against current slot rows, don't snapshot access at login
Cart state survives after a session logs outCart lived in localStorage with no ownership scopingIf 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.

Keep reading

Tutorial10 min read

Email Signup Landing Pages: Where the Capture Point Actually Goes

Squeeze-page advice says strip the page to one form. Only 14 of 66 landing templates in this catalog do — audited against this storefront's own single, sitewide capture point.

Read more
Tutorial8 min read

forEach in TypeScript: 4 Uses Against 149 Maps

A census of a real TypeScript codebase: 4 forEach calls, 149 .map() and 137 for...of. What the four have in common, and the async trap that explains the ratio.

Read more
Tutorial12 min read

Generative Engine Optimization: Building a Site AI Answers Can Cite

An audit of our own site against the standard, including the two places it falls short and the checklist item we deliberately do not ship. The unit of success is a passage, not a page.

Read more