Marketplaces

Building a marketplace with Next.js

We earn commissions when you shop through the links below.

Model sellers, listings, and orders in Prisma, and charge for a listing with a one-time Stripe Checkout Session.

3 min read

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.

Seller dashboard

Sellers need a private list of their listings and a form that creates another row. Keep that on /dashboard, gated by currentUser(). The public catalog should query status: "published" only. A draft sitting in the seller dashboard must not appear in search.

The kit’s checkout adapter is the seam for later payouts. Until you add Connect, treat every charge as platform revenue. That is honest, and it matches the 503 message when the Stripe key is missing.

Related: seller and listing architecture and Stripe payments for marketplaces.

Want to skip the setup? The Marketplace Starter already includes listings, seller accounts, categories, and a one-time Checkout Session per listing.

Product updates for developers shipping with Designyff kits.

Guides: Next.js, Stripe, and SaaS starter kits Professional website design for small businesses

Purchased source may be used in personal, commercial, and client projects. Do not redistribute or resell the original source. Terms of Service Privacy Policy

Designyff © 2026 | Starter kits for developers