Claro
Multi-tenant marketplace for digital products. Each creator gets their own storefront, their own payouts, and their own boundary.
A marketplace gets expensive the minute money and access control meet. Funds split across accounts. Webhooks arrive out of order. Paid content has to unlock for the right buyer and nobody else. Get that wrong and revenue goes sideways fast.
Claro lets creators sell digital products from their own storefronts while the platform takes a cut. I built it to learn what actually breaks once a marketplace has real tenant boundaries, real money paths, and real purchase checks. Stripe Connect Express handles KYC and payouts, which means I'm not pretending to be a bank before there's a reason to. Checkout routes to the creator's connected account and the platform fee comes out in the same flow.

Click image to enlarge
1 / 4
404 instead of 403 for locked content
Purchase checks run server-side before anything sensitive ships. If you didn't buy it, you get a 404, not a 403. A 403 tells people the thing exists. A 404 keeps its mouth shut.
Webhook idempotency before the order write
Orders are idempotent on stripeCheckoutSessionId. Stripe retries webhooks, and a timeout halfway through a write should not create a second order. That is exactly the kind of bug that looks small until money is involved.
Tenant isolation enforced in the data layer, not the UI
Hiding admin UI is not the guard. Payload access rules return where clauses so tenant admins only see their own products. The rule lives underneath the screen, which is where it belongs.
Server-side re-check before Stripe Checkout opens
Carts live in Zustand with localStorage, one cart per tenant. If someone shops two storefronts, those line items stay separated. Opening Checkout still goes through a server-side re-check. Browser state is convenient. It is not in charge.
- 1BrowseShop the main marketplace (filters, search) or a single creator storefront. Listings can stay private so they never show in global discovery.
- 2CartOne cart per creator - if someone buys from two shops, those carts stay separate. The browser cart is for convenience; it is not the source of truth.
- 3Checkout prepThe server re-checks line items and prices before Stripe Checkout opens - so nobody checks out with stale or tampered totals.
- 4Pay on StripeCustomer pays on Stripe's page. Funds route to the creator's connected account and the platform fee is taken in the same flow.
- 5Order savedStripe notifies the server with a signed webhook; only then is the order written. If Stripe retries the same event, you don't get duplicate orders.
- 6Library accessPurchased content unlocks after a server-side purchase check. If you did not buy, you see a generic not-found page instead of a forbidden page - less signal to outsiders.
Domain modules and one path for data
I split the repo by domain under src/modules. Each area owns its tRPC router, UI, hooks, and schemas. Data moves through one path, not five accidental ones. That matters once the codebase grows.
Payload as source of truth for auth and collections
Payload CMS 3 holds collections, auth, and admin. The tRPC context caches Payload, and protectedProcedure checks the session before business logic runs. Types flow from Payload to the UI so the schema and the screen do not drift apart.
Fail deploy when secrets or AI flags are wrong
All env vars validate with Zod at startup. Missing STRIPE_WEBHOOK_SECRET, blob token, or anything else required throws immediately. Same if the AI flag is on without an Anthropic key. I'd rather fail on deploy than during someone's checkout.
Signed webhooks before any side effects
The Stripe webhook verifies the signature with STRIPE_WEBHOOK_SECRET before it does anything else. Only then does it touch checkout.session.completed or account.updated. Money paths do not get the benefit of the doubt.
Marketplace surface built for a real catalog, not a seed script
There's a global marketplace with categories, search, filters, price, tags, and sorts. Listings use cursor-based infinite scroll because a real catalog grows fast. Private products can skip global discovery and live only on the tenant storefront. Trending and bestseller use counters updated from Payload hooks, so I am not running heavy COUNT queries on every page load. That works in dev. Until it doesn't.
Purchased content only, with Lexical rendering
You bought it, you get the library entry and the Lexical-rendered content. You did not buy it, nothing sensitive ships. The purchase check happens server-side first.
Purchase and review rules live in tRPC
One review per user per product, and you have to purchase first. tRPC enforces that so the client cannot fake it. The optional Claude Haiku helper returns tool-shaped, Zod-checked output behind a feature flag. You approve the draft or throw it away. It never posts itself.
Blob storage and tenant subdomains behind flags
Media uploads use Vercel Blob in production, with local disk as the dev fallback when the blob token is missing. Optional subdomain routing for [slug].domain.com sits behind an env flag. Production still needs the DNS story written down, same as any real rollout.
- Before a public launch I'd close the main abuse path on generateReviewDraft with Redis-backed rate limits and I'd add alerting on Stripe webhook failures before calling payouts production-ready. Retries can hide real breakage.
- I'd load-test cursor pagination and filters earlier. The bugs I care about show up when the catalog is big, not when there are twelve seed rows.
- I'd also write migration runbooks for multi-tenant changes the same way I'd hand them off to a client team. That's where this kind of product gets risky.
Core
Supporting


