Recipe · checked November 7, 2026

Marketplace where customers pay providers and you keep a cut

A two-sided web app: providers list a service, customers book and pay, the platform keeps a percentage and pays providers out. Built on Next.js and Postgres, with Stripe Connect for onboarding, identity checks, split payments and payouts.

Tool bills at 1,000 usersAbout $40/month
Setup steps9
Tools in the stack8

Fits when

  • Customers pay providers through your product and you keep a fee on each payment.
  • Providers are individuals or small businesses who need to be onboarded, verified and paid out.
  • Payment is for a service or a physical good, so app-store in-app purchase rules don't apply.

Not for

  • Digital content sold inside an iOS or Android app: the stores' in-app purchase applies. Use the mobile-subscription-app recipe.
  • Platforms that hold customer money for long periods, lend, or run wallets: that's money transmission and needs legal review first.
  • A single seller charging their own customers: plain Stripe is simpler (see the b2b-saas recipe's billing step).

The stack

CategoryPickWhyPick something else when
HostingVercelNext.js hosting with preview deployments. The free Hobby plan is non-commercial, so a marketplace that takes money needs Pro.
  • Render: You need long-running workers for matching, reminders or payouts in the same app.
DatabaseNeonPostgres with transactions, exclusion constraints for double-booking, and extensions for location search; a database branch per preview.
  • Supabase: You want database, auth, storage and realtime chat from one vendor.
AuthClerkPrebuilt sign-in for two kinds of users. Keep the customer or provider role in your own database, set by the server.
  • Better Auth: You want users in your own Postgres with no per-user fees.
  • Supabase Auth: You chose Supabase for the database.
PaymentsStripe ConnectOnboards and verifies providers on Stripe-hosted pages, splits each payment so your fee stays with you, and pays providers to their bank.—
EmailResendBooking confirmations, reminders and cancellation notices from your own domain.
  • Postmark: Transactional deliverability and long retention matter most.
MonitoringSentryError tracking for the booking and payment paths, where a silent failure costs a provider money.
  • Better Stack: You also want uptime checks and a status page.
AnalyticsoptionalPostHogFunnels on both sides: provider sign-up to first booking, and customer search to paid booking.
  • Plausible: You only need traffic stats for the marketing site.
StorageoptionalCloudflare R2Provider photos and documents with no egress fees.
  • Vercel Blob: Uploads are small and you want zero setup on Vercel.

About $40/month at 1,000 users in tool bills: Vercel $20 + Resend $20; $0 for Neon, Clerk, Stripe Connect, Sentry. Usage profile: 1,000 monthly active users with light use: about 100,000 requests, 3,000 emails, 200,000 analytics events and 5 GB of stored files a month, and a database under 1 GB. AI apps add 50 questions per user a month at about 4,000 input and 500 output tokens each (200M input and 25M output tokens). Internal tools are priced for 10 staff instead.

Catches that change the plan

Setup steps

  1. Scaffold the app and choose who handles Stripe pricing

    Decide one Connect setting first, because it changes your fees and your tax filing: whether Stripe bills providers for payment fees, or you set the pricing.

    • Create a Next.js app with one validated env module for Stripe, Clerk and the database.
    • In the Stripe dashboard, set up Connect as a marketplace. Stripe recommends describing connected accounts with controller properties (who pays fees, who's liable, which dashboard) rather than the older Standard, Express and Custom types.
    • If Stripe handles pricing for connected accounts, there are no extra Connect fees and Stripe issues 1099-K forms where required. If you handle pricing, Connect adds $2 per monthly active account, 0.25% + 25¢ per payout and 0.25% of payout volume, and you file any 1099s.
    • For a local service marketplace, give providers the Stripe-hosted dashboard and onboarding so you don't build KYC screens.
    npx create-next-app@latest my-marketplace --ts --eslint --app --src-dir
    npm i stripe zod @clerk/nextjs

    Done when: You can state who pays Stripe's fees, who is liable for refunds and disputes, and who files 1099s.

    Docs: stripe.com, docs.stripe.com

  2. Model providers, bookings and money in Postgres

    Bookings move through states, and every money movement gets a row you can reconcile against Stripe.

    • Create users with a role, providers with their Stripe account id and onboarding flags, services, availability and bookings.
    • Stop double-booking in the database with an exclusion constraint on the provider and time range, not only in code.
    • Record each payment, platform fee, transfer and refund in a payments table keyed by Stripe ids.
    • For 'near me' search, store a location per provider and use the earthdistance extension (with cube) or PostGIS; check your Postgres provider's list of supported extensions.
    • Keep a stripe_events table for webhook idempotency.
    db/001_marketplace.sql
    create extension if not exists btree_gist;
    
    create table users (
      id uuid primary key default gen_random_uuid(),
      auth_user_id text unique not null,
      email text not null,
      role text not null check (role in ('customer','provider','admin')),
      created_at timestamptz not null default now()
    );
    
    create table providers (
      user_id uuid primary key references users(id) on delete cascade,
      stripe_account_id text unique,
      charges_enabled boolean not null default false,   -- mirrored from account.updated
      payouts_enabled boolean not null default false,
      background_check text not null default 'none' check (background_check in ('none','pending','clear','consider')),
      lat double precision,
      lng double precision
    );
    
    create table services (
      id uuid primary key default gen_random_uuid(),
      provider_id uuid not null references providers(user_id) on delete cascade,
      title text not null,
      price_cents integer not null check (price_cents > 0),
      duration_minutes integer not null
    );
    
    create table bookings (
      id uuid primary key default gen_random_uuid(),
      service_id uuid not null references services(id),
      provider_id uuid not null references providers(user_id),
      customer_id uuid not null references users(id),
      during tstzrange not null,
      status text not null check (status in ('pending_payment','confirmed','completed','cancelled','refunded')),
      created_at timestamptz not null default now(),
      -- no two live bookings for one provider may overlap
      exclude using gist (provider_id with =, during with &&) where (status in ('pending_payment','confirmed'))
    );
    
    create table payments (
      id bigserial primary key,
      booking_id uuid not null references bookings(id),
      stripe_payment_intent text unique,
      amount_cents integer not null,
      platform_fee_cents integer not null,
      refunded_cents integer not null default 0,
      status text not null,
      created_at timestamptz not null default now()
    );
    
    create table stripe_events (
      id text primary key,
      type text not null,
      processed_at timestamptz not null default now()
    );
    

    Done when: Inserting two overlapping confirmed bookings for one provider fails in the database.

    Docs: postgresql.org, neon.com

  3. Add sign-in with two roles

    Customers and providers sign in the same way; what they can do depends on a role your server sets.

    • Add Clerk's Next.js middleware and sign-in pages.
    • On first sign-in, create the users row with role customer. Switching to provider is a server action that starts Stripe onboarding.
    • Check the role on the server for every provider-only action; never trust a role from the browser.
    • Let admins see all bookings and payments, and log every admin action.

    Done when: A customer account can't open provider pages or change a service's price.

    Docs: clerk.com

  4. Onboard providers with Stripe Connect

    Providers give their identity and bank details to Stripe, not to you. List them only once Stripe says they can be paid.

    • Create a connected account for each provider and save its id on the providers row.
    • Send the provider to a Stripe-hosted Account Link for onboarding; handle the return and refresh URLs.
    • Listen for account.updated and mirror charges_enabled and payouts_enabled.
    • Hide a provider from search until both are true, and show them what's missing if Stripe asks for more information later.
    src/app/api/connect/onboard/route.ts
    import Stripe from "stripe";
    import { env } from "@/env";
    import { db, currentProvider } from "@/db";
    
    const stripe = new Stripe(env.STRIPE_SECRET_KEY);
    
    export async function POST() {
      const provider = await currentProvider(); // server-side session check
      let accountId = provider.stripe_account_id;
      if (!accountId) {
        // Shape the account to the setup you chose in step 1 (see Stripe's Connect docs).
        const account = await stripe.accounts.create({
          email: provider.email,
          controller: {
            fees: { payer: "application" },
            losses: { payments: "application" },
            stripe_dashboard: { type: "express" },
          },
        });
        accountId = account.id;
        await db.setStripeAccount(provider.user_id, accountId);
      }
      const link = await stripe.accountLinks.create({
        account: accountId,
        type: "account_onboarding",
        refresh_url: `${env.APP_URL}/provider/onboarding?retry=1`,
        return_url: `${env.APP_URL}/provider/onboarding/done`,
      });
      return Response.json({ url: link.url });
    }
    

    Done when: A test provider completes onboarding with Stripe's test data and shows charges_enabled and payouts_enabled in your table.

    Docs: docs.stripe.com, docs.stripe.com

  5. Take payment and keep your fee

    Use destination charges: the customer pays the platform, Stripe moves the provider's share to their account, and your fee stays in your balance. Stripe's docs call this the best fit for marketplaces.

    • Create a Checkout Session in payment mode with payment_intent_data.transfer_data.destination set to the provider's account and application_fee_amount set to your cut (15% of the price, in cents).
    • Put the booking id in metadata, and create the booking as pending_payment so the slot is held.
    • Confirm the booking only from the webhook, never from the redirect back to your site.
    • Release unpaid held slots after a short timeout so abandoned checkouts don't block providers.
    • For bookings far in the future, charge at booking and refund on cancellation; card authorizations expire, so check Stripe's window before relying on authorize-now, capture-later.
    src/app/api/bookings/checkout/route.ts
    import Stripe from "stripe";
    import { env } from "@/env";
    import { db } from "@/db";
    
    const stripe = new Stripe(env.STRIPE_SECRET_KEY);
    const TAKE_RATE = 0.15;
    
    export async function POST(req: Request) {
      const { serviceId, start } = await req.json();
      const booking = await db.holdSlot(serviceId, new Date(start)); // inserts pending_payment; fails on overlap
      const fee = Math.round(booking.price_cents * TAKE_RATE);
      const session = await stripe.checkout.sessions.create({
        mode: "payment",
        line_items: [{ price_data: { currency: "usd", unit_amount: booking.price_cents, product_data: { name: booking.title } }, quantity: 1 }],
        payment_intent_data: {
          application_fee_amount: fee,
          transfer_data: { destination: booking.provider_stripe_account_id },
          metadata: { booking_id: booking.id },
        },
        metadata: { booking_id: booking.id },
        success_url: `${env.APP_URL}/bookings/${booking.id}?paid=1`,
        cancel_url: `${env.APP_URL}/bookings/${booking.id}?cancelled=1`,
      });
      return Response.json({ url: session.url });
    }
    

    Done when: In test mode, a $25 booking shows $25 charged, $3.75 application fee in your balance, and the rest transferred to the provider.

    Docs: docs.stripe.com, docs.stripe.com

  6. Handle webhooks, refunds and disputes

    With destination charges, refunds and chargebacks come out of the platform's balance first, so your code must reverse the provider's share deliberately.

    • Verify every webhook signature on the raw body and skip event ids you've already processed.
    • Handle checkout.session.completed (confirm the booking), charge.refunded, charge.dispute.created and account.updated.
    • Add a second endpoint, or enable connected-account events, for events that happen on providers' accounts, such as payouts.
    • Refund with reverse_transfer so the provider's share comes back, and decide per your policy whether to refund_application_fee.
    • Write the cancellation and refund policy into your terms before launch, and apply it in code the same way every time.
    stripe listen --forward-to localhost:3000/api/stripe/webhook --forward-connect-to localhost:3000/api/stripe/connect-webhook

    Done when: A refunded test booking returns the customer's money, takes back the provider's transfer, and replaying the webhook changes nothing.

    Docs: docs.stripe.com, docs.stripe.com

  7. Build trust and safety in

    Stripe verifies who providers are for payments. Whether they're safe to send into a home is a separate check.

    • For in-home or care services, run background checks with a consumer reporting agency (Checkr Basic starts at $29.99 a report plus pass-through fees) and follow the FCRA consent and adverse-action steps.
    • Allow reviews only from customers with a completed booking, one per booking.
    • Keep messaging inside the app until a booking is paid, so payments don't leak off the platform.
    • Add report and block buttons, and a way for admins to pause a provider.

    Done when: A provider without a clear check can't be booked for in-home services, and a review can't be left without a completed booking.

    Docs: checkr.com

  8. Send booking email and reminders

    Both sides need a confirmation, a reminder before the booking, and a notice when anything changes.

    • Verify a sending subdomain in Resend with SPF and DKIM, and add DMARC on the root domain.
    • Send booking confirmed, reminder the day before, cancelled, refunded and payout sent.
    • Run reminders from a scheduled job that reads upcoming confirmed bookings, and record each send to avoid duplicates.
    npm i resend @react-email/components

    Done when: A test booking sends both sides a confirmation and a reminder, once each.

    Docs: resend.com

  9. Go live

    Switch to live mode only when hosting, terms and tax setup are ready.

    • Move the Vercel project to Pro before the first real payment; Hobby is for non-commercial use.
    • Publish terms that cover your fee, cancellations, refunds and who provides the service, and a privacy policy that lists your vendors.
    • Complete the Connect platform profile in the Stripe dashboard, then create live connected accounts; test accounts don't carry over.
    • Add Sentry and tag errors with booking id, never with personal data.
    • Ask a tax professional whether you collect sales tax on your service fee in your states.
    npx @sentry/wizard@latest -i nextjs

    Done when: One real booking between two people you know goes from search to payout without manual fixes.

    Docs: docs.stripe.com, vercel.com

Best practices

  • Choose who handles Stripe pricing before you build; it decides your Connect fees and who files tax forms.
  • Confirm bookings and grant anything of value only from verified webhooks.
  • Prevent double-booking with a database constraint, not just a check in code.
  • Record every charge, fee, transfer and refund with its Stripe id so you can reconcile each payout.
  • List a provider only when Stripe reports both charges and payouts enabled.
  • Write the refund policy once and apply it in code the same way every time.

Pitfalls

  • Collecting money on your own Stripe account and paying providers by hand; that's money transmission territory and a reconciliation mess.
  • Using direct charges when you want to own the customer relationship and liability, or destination charges without planning for refunds coming out of your balance.
  • Refunding without reverse_transfer, so the platform pays the whole refund while the provider keeps their share.
  • Launching on Vercel Hobby, which is for non-commercial use.
  • Letting customers and providers swap phone numbers before the first payment.

Costs and obligations

These point to obligations to check; they aren't legal or tax advice.