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.

2 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.

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.

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

Designyff © 2026 | Starter kits for developers | Powered by  Tacko