Best Boilerplates for Building Marketplace Platforms 2026
·StarterPick Team
marketplacestripe-connecttwo-sidedplatformboilerplate2026
TL;DR
Building a marketplace is 10x harder than a regular SaaS because you have two customer types (buyers + sellers) with different flows, and money movement between them. The core complexity is always Stripe Connect for split payments and payouts. No boilerplate handles this fully — you combine a SaaS starter + custom Stripe Connect implementation. The most important decision: standard (seller can't touch funds) vs express accounts (seller has a Stripe dashboard).
Key Takeaways
- Stripe Connect: Non-negotiable for any real marketplace with money movement
- Connect account type: Express (seller has Stripe dashboard) vs Standard vs Custom
- Application fee: Stripe's built-in mechanism for platform fee on each transaction
- Transfers: Separate payment from payout — hold funds, release after service delivery
- Seller onboarding: OAuth flow for Standard; hosted onboarding for Express
- Tax reporting: Stripe handles 1099 generation for US marketplaces with Connect
Marketplace Architecture
Buyer pays → Stripe (platform account)
↓
Split: 85% → Seller account
+ 15% → Platform fee (your revenue)
Timeline:
1. Buyer pays → Funds held in platform Stripe account
2. Service delivered / product shipped
3. Platform releases funds → Seller's Stripe account
4. Seller transfers to bank account (2-7 day payout)
Stripe Connect: Express Accounts (Recommended)
// lib/stripe-connect.ts:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Step 1: Create Express account onboarding link for seller:
export async function createSellerOnboardingLink(sellerId: string) {
// First, create (or get existing) Connect account:
const account = await stripe.accounts.create({
type: 'express',
metadata: { sellerId },
capabilities: {
transfers: { requested: true },
card_payments: { requested: true },
},
});
// Save account ID to your DB:
await db.seller.update({
where: { id: sellerId },
data: { stripeAccountId: account.id },
});
// Generate onboarding link:
const link = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${process.env.NEXT_PUBLIC_URL}/seller/onboarding/refresh`,
return_url: `${process.env.NEXT_PUBLIC_URL}/seller/dashboard`,
type: 'account_onboarding',
});
return link.url; // Redirect seller to this URL
}
// Check if seller completed onboarding:
export async function checkSellerOnboarded(stripeAccountId: string) {
const account = await stripe.accounts.retrieve(stripeAccountId);
return account.details_submitted && !account.requirements?.currently_due?.length;
}
// Step 2: Create payment with application fee (the platform fee):
export async function createMarketplacePayment(params: {
amount: number; // Total amount in cents ($100 = 10000)
platformFeePercent: number; // e.g., 0.15 for 15%
sellerStripeAccountId: string;
description: string;
metadata: Record<string, string>;
}) {
const { amount, platformFeePercent, sellerStripeAccountId } = params;
const applicationFeeAmount = Math.round(amount * platformFeePercent);
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: 'usd',
application_fee_amount: applicationFeeAmount, // Platform gets this
transfer_data: {
destination: sellerStripeAccountId, // Seller gets the rest
},
metadata: params.metadata,
});
return paymentIntent;
}
// Or with Checkout Session:
export async function createCheckoutSession(params: {
lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
sellerAccountId: string;
platformFeePercent: number;
}) {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: params.lineItems,
payment_intent_data: {
application_fee_amount: calculateFee(params.lineItems, params.platformFeePercent),
transfer_data: { destination: params.sellerAccountId },
},
success_url: `${process.env.NEXT_PUBLIC_URL}/orders/{CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/cancel`,
});
return session.url;
}
Service Marketplace: Escrow Pattern
// For service marketplaces (Fiverr-like) — hold funds until delivery:
// 1. Capture payment but don't transfer yet:
const paymentIntent = await stripe.paymentIntents.create({
amount: 10000, // $100
currency: 'usd',
capture_method: 'manual', // Don't capture yet (auth only)
transfer_data: { destination: sellerAccountId },
application_fee_amount: 1500, // $15 platform fee
});
// 2. When buyer confirms delivery — capture and release:
async function releasePayment(paymentIntentId: string) {
await stripe.paymentIntents.capture(paymentIntentId);
// The transfer to seller happens automatically
// Update order status:
await db.order.update({
where: { stripePaymentIntentId: paymentIntentId },
data: { status: 'completed', completedAt: new Date() },
});
}
// 3. If dispute — refund buyer:
async function refundBuyer(paymentIntentId: string) {
await stripe.paymentIntents.cancel(paymentIntentId);
// or for already captured: stripe.refunds.create({ payment_intent: id })
}
Seller Dashboard: Payout Status
// Seller can see their Stripe Express dashboard:
async function getSellerDashboardLink(sellerStripeAccountId: string) {
const link = await stripe.accounts.createLoginLink(sellerStripeAccountId);
return link.url; // Direct link to Stripe Express dashboard
}
// Or build your own payout display:
async function getSellerBalance(sellerStripeAccountId: string) {
const balance = await stripe.balance.retrieve({
stripeAccount: sellerStripeAccountId,
});
return {
available: balance.available[0]?.amount ?? 0, // Ready to payout
pending: balance.pending[0]?.amount ?? 0, // Not yet settled
};
}
Listing Management
// Marketplace listing schema:
model Listing {
id String @id @default(cuid())
sellerId String
seller Seller @relation(fields: [sellerId], references: [id])
title String
description String
price Int // Cents
category String
images String[]
status ListingStatus // DRAFT, ACTIVE, PAUSED, SOLD
featured Boolean @default(false)
// For physical products:
stock Int?
shipping Json? // { methods: [{name, price, days}] }
// For services:
deliveryDays Int?
createdAt DateTime @default(now())
}
// Buyer search/filter:
async function searchListings(params: {
query?: string;
category?: string;
minPrice?: number;
maxPrice?: number;
sortBy: 'newest' | 'price_asc' | 'price_desc' | 'popular';
}) {
const listings = await db.listing.findMany({
where: {
status: 'ACTIVE',
...(params.query && {
OR: [
{ title: { contains: params.query, mode: 'insensitive' } },
{ description: { contains: params.query, mode: 'insensitive' } },
],
}),
...(params.category && { category: params.category }),
price: {
gte: (params.minPrice ?? 0) * 100,
lte: (params.maxPrice ?? 99999) * 100,
},
},
orderBy: {
newest: { createdAt: 'desc' },
price_asc: { price: 'asc' },
price_desc: { price: 'desc' },
popular: { orders: { _count: 'desc' } },
}[params.sortBy],
include: { seller: { select: { name: true, rating: true, avatarUrl: true } } },
});
return listings;
}
Decision Guide
Use Express Connect if:
→ Sellers are individuals (gig economy, freelancers)
→ You want Stripe to handle seller compliance
→ Sellers need their own payout dashboard
→ 95% of marketplace use cases
Use Standard Connect if:
→ Sellers are businesses with existing Stripe accounts
→ Sellers want their own Stripe account management
→ B2B marketplace (SaaS vendors selling through your platform)
Use Custom Connect if:
→ Complete control over onboarding UI/UX
→ White-label the payment experience
→ Complex compliance requirements
→ Significant engineering investment required
Best boilerplate starting points for marketplaces:
→ ShipFast + custom Stripe Connect (fastest MVP)
→ T3 Stack + custom Stripe Connect (TypeScript-first)
→ Medusa.js (ecommerce-focused, has marketplace plugin)
→ No pre-built marketplace boilerplate handles Connect well — build on top
Find marketplace boilerplates at StarterPick.