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:
- The Click The user triggers the
addItemaction within theuseCarthook. - State Transition We set a local
justAddedstate totrue. - Visual Confirmation The button text immediately shifts to a checkmark or "In Cart," providing instant feedback.
- The
setTimeoutTimer A 3000ms timer begins. - The Reset
justAddedreverts tofalse, 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:
stripeEventIdConstraint We store every processed event ID in a unique DB column. Duplicates get rejected immediately.sessionKeyPattern Because one Stripe session now creates multiple purchase records, thestripeSessionIdis no longer a unique key. We use a compositesessionKey(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.
