← Selected work
Case study

Claro

Multi-tenant marketplace for digital products. Each creator gets their own storefront, their own payouts, and their own boundary.

The problem

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.

What I built

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.

Screenshots
Global discovery and filters - built for a catalog that grows, not a seed grid.

Click image to enlarge

1 / 4

Decisions that matter

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.

How it works
Buyer: from browse to content
  1. 1
    BrowseShop the main marketplace (filters, search) or a single creator storefront. Listings can stay private so they never show in global discovery.
  2. 2
    CartOne 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.
  3. 3
    Checkout prepThe server re-checks line items and prices before Stripe Checkout opens - so nobody checks out with stale or tampered totals.
  4. 4
    Pay 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.
  5. 5
    Order 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.
  6. 6
    Library 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.
Creator: money without building a bank
Onboard with StripeCreators use Stripe Connect Express - KYC, compliance, and payout mechanics stay with Stripe, not in custom code.
List productsEach creator only manages their own catalog. That boundary is enforced in the CMS / database layer - hiding buttons is not the security model.
They get paidCheckout is tied to their account; the platform cut is applied automatically on each charge.
Stripe moves the moneyI wire up the APIs and webhooks - I do not rebuild banking or payout compliance myself.
What matters in production

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.

What I'd tighten
  • 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.
Stack

Core

Next.js 15TypeScriptPayload CMS 3MongoDBtRPC v11Stripe ConnectStripe Checkout

Supporting

ZodTanStack QueryZustand
View code →