Marketplaces
Building a marketplace with Next.js
Model sellers, listings, and orders in Prisma, and charge for a listing with a one-time Stripe Checkout Session.
A marketplace starter is a catalog plus a checkout for one listing. It is not a payouts platform. The Marketplace Starter says so in the route: if STRIPE_SECRET_KEY is missing it returns 503 with “Payout splitting is intentionally not included.” Money is charged by you, in EUR, through a normal Checkout Session.
Models
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
listings Listing[]
}
model Listing {
id String @id @default(cuid())
slug String @unique
title String
summary String
priceCents Int
status String @default("published")
sellerId String
categoryId String
}
model Order {
id String @id @default(cuid())
listingId String
buyerEmail String
status String @default("pending")
}
priceCents is an integer. 2400 is 24.00 EUR. User here is the seller account, with a password hash, not a Stripe Connect account.
Checkout
POST /api/checkout/[id] loads the listing by id (not slug), then:
const session = await stripe().checkout.sessions.create({
mode: "payment",
line_items: [{
quantity: 1,
price_data: {
currency: "eur",
unit_amount: listing.priceCents,
product_data: { name: listing.title }
}
}],
success_url: `${process.env.APP_URL}/dashboard?paid=1`,
cancel_url: `${process.env.APP_URL}/listings/${listing.slug}`,
metadata: { listingId: listing.id }
});
await prisma.order.create({
data: { listingId: listing.id, buyerEmail: "checkout", status: "pending" }
});
return NextResponse.redirect(session.url || "/", 303);
The order row is created before the redirect, with buyerEmail set to the literal "checkout" and status "pending". The kit does not collect the buyer’s email in this handler, and it does not mark the order paid when Checkout returns. ?paid=1 on the dashboard is a query flag, not a verified payment. To record a paid order, retrieve the session the way the job board does, or handle checkout.session.completed and set Order.status from metadata.listingId.
303 matters because the route is POST. A 302 can make some clients repeat the POST.
What you still build
Seller profiles are the User row plus that user’s listings. Categories are a slug and a name, same as the Directory Starter. There is no application fee, no transfer to the seller, and no stripeAccount field. Add Stripe Connect only when a seller must be paid out. Until then, describe the product as a catalog with Checkout, which is what the code does.
Want to skip the setup? The Marketplace Starter already includes listings, seller accounts, categories, and a one-time Checkout Session per listing.