Thinking Out Loud

Building the Creator Store v2: The Technical and Emotional Debt of the Multi-Item Cart

Anthony Shead/June 10, 2026/6 min read
Building the Creator Store v2: The Technical and Emotional Debt of the Multi-Item Cart
fig. 1 - Building the Creator Store v2: The Technical and Emotional Debt of the Multi-Item Cart

Introduction: The "V1" Illusion and the Reality of Scale

I'll be honest: V1 was an illusion of simplicity that masked a looming architectural debt. When we launched the first version of the Creator Store, it was a straight line: one product, one price, one immediate redirect to Stripe. It was efficient, but it wasn't a store; it was a series of isolated transaction pages. Moving to V2, where a user can browse, collect multiple items, and manage a persistent cart, has been an exhausting architectural war. I spent far too many nights at 3:00 AM chasing "ghost items" that appeared or vanished because a user refreshed their browser at the exact wrong millisecond. Building a button that says "Add to Cart" sounds trivial until you realize it's the front line of a battle for data integrity and user trust.


The Heart of the Overhaul: Architecture of the Multi-Item Cart

The most significant technical shift in V2 is the move to a centralized state management system that survives the volatility of the browser.

The Transition to useCart.

We moved away from "temporary state" living on individual pages and created a custom React hook: hooks/use-cart.ts. This hook acts as the single source of truth. Whether a user is on a Product Detail Page, looking at the Header cart indicator, or reviewing their Order Summary, the data remains consistent and reactive.

The Persistence Struggle

A major decision was where to store this cart data. We ultimately chose localStorage for the primary persistence layer to prioritize speed and guest access.

Technical Trade-offs: Cart State Storage

Approach Pros Cons
localStorage Zero-latency; guest access; no unnecessary DB writes Siloed to one device; clearable by user
Database-backed Cross-device sync; abandoned cart analytics Adds latency; requires auth on every cart interaction

The Sync Problem

To prevent the "stale cart" ghosting effect where a user adds an item in one tab but doesn't see it in another, we implemented useSyncExternalStore. This React hook subscribes to localStorage events across all open tabs, ensuring that the cart count and items are perfectly synchronized. It eliminates the vulnerable moment where a user might think their item wasn't added because they're looking at a stale browser window.


Frontend Friction: Engineering a Non-Destructive "Add to Cart"

In V1, clicking "Buy" took you straight to payment. In V2, we had to slow the user down to respect their browsing experience.

The Psychology of Feedback

Immediate redirects in a multi-item store feel destructive. A non-destructive "Add to Cart" button feels professional because it doesn't hijack the user's session. But this requires a much higher level of visual confirmation to reduce confusion.

The Step-by-Step Button Breakdown:

  1. The Click The user triggers the addItem action within the useCart hook.
  2. State Transition We set a local justAdded state to true.
  3. Visual Confirmation The button text immediately shifts to a checkmark or "In Cart," providing instant feedback.
  4. The setTimeout Timer A 3000ms timer begins.
  5. The Reset justAdded reverts to false, returning the button to its default so the user can keep shopping.

Context-Aware UI

The button is also the first line of defense against duplicate purchases. By checking the user's ownedProducts state (fetched server-side), the button dynamically changes. If a product is already owned, it replaces "Add to Cart" with "View in Library," linking directly to the Account Library.


The "Order Summary" Shift: The Psychology of the Promo Code

The Order Summary is where the final payment decision happens, and it's a high-friction zone.

Why We Moved It

The promo code input used to live at the top of the page, physically and mentally disconnected from the subtotal. We moved it directly into the Order Summary panel, right next to the "Pay" button. This prevents checkout abandonment caused by users hunting for where to apply a discount.

Verification States and User Trust

  • Loading Disables the "Apply" button to prevent double-clicks while the API validates.
  • Success Immediately updates the total, giving that hit of confirmation that keeps the user in the funnel.
  • Invalid Provides a clear, calm error message, maintaining trust even during a failure.

The Invisible Safety Net: Duplicate Protection and Webhook War Stories

The backend is where the real battle for data integrity is fought, specifically handling "Mixed Carts" containing both owned and unowned products.

Recommended Strategy: Filter owned products at the API level before creating a Stripe session. If a user attempts to check out with only owned products, return a 409 Conflict. For mixed carts, remove the owned items, proceed with the unowned items, and pass removedOwnedProducts metadata back to the frontend so the user knows why their total changed.

The Rounding Fix

When splitting a total payment across multiple items, math can be an enemy. If a user buys three items for $9.99, a naive split results in $3.33 per item, leaving $0.01 floating in the void. Our fulfillment logic uses a rounding fix: we divide the total, and the first product in the array receives the remainder ($3.34, $3.33, $3.33). Stripe records match to the cent.

Stripe Metadata and JSON Serialization

Stripe only accepts strings for metadata. To pass a list of products to our webhook:

// Creating the session
metadata: { productIds: JSON.stringify([id1, id2]) }

// Inside the webhook
const productIds = JSON.parse(event.data.object.metadata.productIds)

Webhook Idempotency: The Final Shield

Stripe webhooks are not guaranteed to arrive only once. Our two-layer protection:

  1. stripeEventId Constraint We store every processed event ID in a unique DB column. Duplicates get rejected immediately.
  2. sessionKey Pattern Because one Stripe session now creates multiple purchase records, the stripeSessionId is no longer a unique key. We use a composite sessionKey (checkoutSessionId_productId) to track each item's fulfillment.

If we encounter a P2002 error (Prisma's unique constraint violation), we catch it and treat the event as a duplicate, preventing double fulfillment.


Refining the Aesthetic: Color-Coding and Type Safety

We used strict logic to keep the UI predictable. By using a getCategoryColor helper and TypeScript Record types, we ensure a "Template" badge always has the same visual weight.

To prevent Tailwind CSS from purging our dynamic styles, we store full Tailwind classes (text-blue-600) in our maps rather than dynamic string templates (text-${color}-600).


The End Goal: A Reliable Home in the "Account Library"

The Account Library is the ultimate destination for the user's trust: an enriched view of purchases, product details, and licenses.

Core questions answered for the customer:

  • What products do I own? Rendered in a visual card grid.
  • Where is my download? Protected server-side routes.
  • What is my license key? Presented in a selectable monospace block for easy copying.
  • Is this still available? Handling archived products where owners keep access.

Conclusion: Raw Reflections

The transition to V2 has been exhausting. While the UI looks cleaner, the backend is now a robust commerce pipeline designed to withstand the chaos of the web. This was invisible work the kind of engineering that users never see until it fails.

New posts, in your inbox

Roughly one a week. Confirm once, unsubscribe anytime.

Writing like this is reader-supported.Support on Patreon →

0 comments

Read next