Building a marketplace means wiring up authentication, role-based data ownership, flexible product schemas, and a public storefront that ties it all together. Most e-commerce platforms lock you into a rigid product taxonomy. The alternative is stitching together a dozen services with custom glue code. Neither works when vendors need to own their own catalogs while sharing a single browsable storefront.
Strapi 5 paired with Next.js 16 gives you a clean split. Strapi ships with a content modeler, JWT-based authentication, and a configurable roles system. Next.js 16 handles server-rendered storefronts and authenticated vendor dashboards. The two communicate over Strapi's REST API.
This tutorial covers modeling vendors, products, and categories as Content-Types, configuring vendor and customer roles, enforcing vendor-scoped writes with a route middleware, and building the public storefront plus a vendor dashboard. The key insight worth flagging early: vendor isolation comes from a single route middleware, not a complex multi-tenant database setup.
In brief:
Before starting, make sure you have:
Scaffold the Strapi backend first. If you've worked through a Strapi 5 guide project before, this will feel familiar:
1npx create-strapi@latest marketplace-apiThe interactive command-line interface (CLI) prompts you to log in or skip (skipping defaults to the free plan). Accept the defaults or configure to your preference. The older create-strapi-app command still works, but create-strapi is the current package name. Strapi 5 still supports the --quickstart flag. See the Strapi installation docs for the full flag reference.
Next, scaffold the frontend:
1npx create-next-app@latest marketplace-webSelect App Router and TypeScript when prompted.
Once both installs finish, Strapi auto-creates an Admin Panel at localhost:1337/admin and Next.js 16 runs at localhost:3000. Start Strapi with npm run develop from the marketplace-api directory, then create your first admin account through the browser.
One note on the .env file Strapi generates: it contains a JWT_SECRET used to sign authentication tokens. Leave it untouched unless you're deploying to production, where you should set it as an environment variable.
Marketplace logic lives in your content types. Get the relations right here, and the rest of the build is plumbing.
Open the Content-Type Builder in the Strapi Admin Panel. Click + Create new collection type and name it Vendor.
Add these fields:
displayName (string, required): the vendor's public-facing name. slug (UID - unique identifier, attached to displayName): auto-generates a URL-safe identifier. The UID field only allows characters matching /^[A-Za-z0-9-_.~]*$/. bio (text): a short description of the vendor. logo (media, single image): the vendor's branding. user (relation, one-to-one with User from Users & Permissions): this links every Vendor profile to exactly one Strapi end user.For the user relation, select the Relation field type, pick "User (from: users-permissions)" on the right side of the relation picker, and choose the one-to-one icon. In the generated schema.json, the target string is "plugin::users-permissions.user".
Why one-to-one? Every Vendor profile is backed by exactly one Strapi user account. The JWT identifies the user. The Vendor profile holds the marketplace-specific data. This separation keeps authentication and authorization concerns cleanly separated from vendor metadata.
Create another Collection Type named Product with these fields:
name (string, required) slug (UID, attached to name) description (rich text, blocks editor): returns structured JSON rather than a markdown string price (decimal) inventory (integer) images (media, multiple): allows several product photos vendor (relation, many-to-one to Vendor): many products belong to one vendor category (relation, many-to-one to Category): many products belong to one categoryEnable Draft & Publish in the advanced settings when creating this type. This lets vendors save products as drafts and publish them later. In the schema.json, that's "draftAndPublish": true.
Create a simple Category Collection Type with two fields: name (string, required) and slug (UID, attached to name).
Save all three content types and restart Strapi with npm run develop. Verify the generated schema files at src/api/product/content-types/product/schema.json. You should see the vendor relation defined as:
1"vendor": {
2 "type": "relation",
3 "relation": "manyToOne",
4 "target": "api::vendor.vendor",
5 "inversedBy": "products"
6}Why many-to-one instead of many-to-many? A product belongs to exactly one vendor in this model. One vendor can have many products, but a product never appears in two vendor catalogs.
For more on how Strapi relations come in six types, including the bidirectional pair governed by mappedBy and inversedBy. If you're new to planning schemas before building them, the content modeling overview is also worth a read.
Strapi's Users and Permissions plugin already gives you built-in public and authenticated access levels. For a marketplace, this guide uses two additional role buckets: Vendor and Customer.
Navigate to Settings > Users & Permissions > Roles and click Add Role.
Create the Vendor role with these permissions:
find, findOne (read access), plus create, update, delete (write access, scoped by the middleware you'll add next) find and findOne find (so vendors can retrieve their own profile)Create the Customer role:
find and findOne find and findOne find and findOne For the Public role, enable only find and findOne on Product, Category, and Vendor. This gives unauthenticated visitors read-only catalog access.
This permission matrix maps directly to the trust boundaries described earlier. For a deeper dive into RBAC guide, that guide covers how changes at the role level instantly affect all assigned users.
New users register through Strapi's built-in endpoint:
1curl -X POST http://localhost:1337/api/auth/local/register \
2 -H "Content-Type: application/json" \
3 -d '{
4 "username": "acme-goods",
5 "email": "vendor@acme.com",
6 "password": "SecurePass123"
7 }'This returns { jwt, user }. The JWT is what the client stores for subsequent authenticated requests. Note that registration automatically assigns the default authenticated access level.
To assign the Vendor role, you need an admin-level request to update the user. Use a user JWT or a full-access API token (not an admin JWT, which only works on /admin endpoints):
1curl -X PUT http://localhost:1337/api/users/{userId} \
2 -H "Content-Type: application/json" \
3 -H "Authorization: Bearer ADMIN_JWT_HERE" \
4 -d '{ "role": 3 }'The role field accepts the numeric id of the target role (check your Roles list to confirm the Vendor role's ID). In production, you'd usually wrap this in a custom registration controller that assigns the intended role during onboarding.
After registration, create the Vendor profile linked to the new user account:
1curl -X POST http://localhost:1337/api/vendors \
2 -H "Content-Type: application/json" \
3 -H "Authorization: Bearer VENDOR_JWT_HERE" \
4 -d '{
5 "data": {
6 "displayName": "Acme Goods",
7 "bio": "Handcrafted artisan products",
8 "user": "USER_DOCUMENT_ID_HERE"
9 }
10 }'In this guide, documentId is the stable identifier used throughout the content API examples. The numeric id still appears in some responses, which is why you'll see both fields referenced in different places later in the article.
Granting Vendors update and delete on Product lets any vendor edit any product. To stop that, every write must be filtered by the authenticated user. The recommended pattern in Strapi 5 is a route middleware.
is-vendor-owner MiddlewareRun the Strapi CLI generator from your project root:
1npm run strapi generateSelect middleware from the interactive menu, name it is-vendor-owner, and scope it to the product API. This creates a file at src/api/product/middlewares/is-vendor-owner.js.
Replace the generated placeholder with this implementation:
1"use strict";
2
3module.exports = (config, { strapi }) => {
4 return async (ctx, next) => {
5 const user = ctx.state.user;
6
7 if (!user) {
8 return ctx.unauthorized("You must be logged in.");
9 }
10
11 // Look up the Vendor profile linked to this user
12 const vendors = await strapi.documents("api::vendor.vendor").findMany({
13 filters: { user: { id: user.id } },
14 limit: 1,
15 });
16
17 const vendor = vendors[0];
18
19 if (!vendor) {
20 return ctx.unauthorized("No vendor profile found for this user.");
21 }
22
23 const entryId = ctx.params.id;
24
25 if (entryId) {
26 // UPDATE or DELETE: verify the target product belongs to this vendor
27 const product = await strapi.documents("api::product.product").findOne(
28 entryId,
29 { populate: "vendor" }
30 );
31
32 if (!product || product.vendor.documentId !== vendor.documentId) {
33 return ctx.unauthorized("This action is unauthorized.");
34 }
35 } else {
36 // CREATE: force the vendor field to the authenticated vendor's documentId
37 ctx.request.body.data.vendor = vendor.documentId;
38 }
39
40 return next();
41 };
42};This matters because trusting the client to send the correct vendor field would let any logged-in vendor create products attributed to a competitor. The middleware ignores whatever vendor identifier the client sends on create and always injects the authenticated vendor's documentId.
On update and delete, it confirms the target product actually belongs to the requesting vendor before allowing the operation through. For more on this pattern applied to CRUD permissions, that tutorial covers the broader approach.
Open src/api/product/routes/product.js and replace the default export:
1const { createCoreRouter } = require("@strapi/strapi").factories;
2
3module.exports = createCoreRouter("api::product.product", {
4 config: {
5 create: {
6 middlewares: ["api::product.is-vendor-owner"],
7 },
8 update: {
9 middlewares: ["api::product.is-vendor-owner"],
10 },
11 delete: {
12 middlewares: ["api::product.is-vendor-owner"],
13 },
14 },
15});The naming pattern is api::api-name.middleware-name. Restart Strapi after saving.
To verify the middleware works, log in as one vendor and attempt to update a product belonging to a different vendor. You should get a 401 response. Here's a concrete test sequence:
1# Log in as Vendor A
2curl -X POST http://localhost:1337/api/auth/local \
3 -H "Content-Type: application/json" \
4 -d '{"identifier": "vendor-a@example.com", "password": "SecurePass123"}'
5
6# Try to update Vendor B's product (should return 401)
7curl -X PUT http://localhost:1337/api/products/VENDOR_B_PRODUCT_DOCUMENT_ID \
8 -H "Content-Type: application/json" \
9 -H "Authorization: Bearer VENDOR_A_JWT" \
10 -d '{"data": {"name": "Hijacked Product"}}'If the middleware is wired correctly, the second request returns { "error": { "status": 401, "message": "This action is unauthorized." } }. If it returns a 200 with the updated product, double-check that the middleware file name matches the string in your route config exactly.
With the backend locked down, the Next.js 16 side stays simple: server components fetch from the public REST endpoints.
Create app/products/page.tsx as a server component:
1const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
2
3async function getProducts() {
4 const res = await fetch(
5 `${STRAPI_URL}/api/products?populate[images][fields][0]=url&populate[images][fields][1]=alternativeText&populate[vendor][fields][0]=displayName&populate[vendor][fields][1]=slug`,
6 { next: { revalidate: 60 } }
7 );
8
9 if (!res.ok) throw new Error("Failed to fetch products");
10 return res.json();
11}
12
13export default async function ProductsPage() {
14 const { data: products } = await getProducts();
15
16 return (
17 <main>
18 <h1>All Products</h1>
19 <div className="grid grid-cols-3 gap-4">
20 {products.map((product: any) => (
21 <div key={product.documentId} className="border p-4 rounded">
22 <h2>{product.name}</h2>
23 <p>${product.price}</p>
24 {product.vendor && <p>by {product.vendor.displayName}</p>}
25 </div>
26 ))}
27 </div>
28 </main>
29 );
30}By default, Strapi 5 returns only scalar fields. Relations and media need explicit population through the populate parameter. The populate guide is a quick refresher on populate and filtering syntax, and the fetch with Strapi tutorial shows additional patterns.
Create the dynamic route at app/vendors/[slug]/page.tsx:
1const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
2
3interface PageProps {
4 params: Promise<{ slug: string }>;
5}
6
7export default async function VendorPage({ params }: PageProps) {
8 const { slug } = await params;
9
10 const res = await fetch(
11 `${STRAPI_URL}/api/vendors?filters[slug][$eq]=${slug}&populate[products][populate][images][fields][0]=url&populate[logo][fields][0]=url`,
12 { next: { revalidate: 60 } }
13 );
14
15 const { data } = await res.json();
16 const vendor = data[0];
17
18 if (!vendor) return <p>Vendor not found</p>;
19
20 return (
21 <main>
22 <h1>{vendor.displayName}</h1>
23 <p>{vendor.bio}</p>
24 <h2>Products</h2>
25 {vendor.products?.map((product: any) => (
26 <div key={product.documentId}>
27 <h3>{product.name}</h3>
28 <p>${product.price}</p>
29 </div>
30 ))}
31 </main>
32 );
33}Note that params is a Promise in Next.js 16 and requires await. The filters[slug][$eq] parameter returns a collection response (an array), so you grab the first element.
Vendors need a place to log in, see their own products, and add new ones. Two routes cover both.
The secure pattern is to never expose the JWT to client-side JavaScript. Create a Next.js 16 route handler at app/api/auth/login/route.ts that proxies the login request and sets an httpOnly cookie. For detailed implementation of this pattern, see the Next.js authentication and password auth tutorials.
1import { cookies } from "next/headers";
2import { NextRequest, NextResponse } from "next/server";
3
4const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
5
6export async function POST(request: NextRequest) {
7 const { identifier, password } = await request.json();
8
9 const strapiRes = await fetch(`${STRAPI_URL}/api/auth/local`, {
10 method: "POST",
11 headers: { "Content-Type": "application/json" },
12 body: JSON.stringify({ identifier, password }),
13 });
14
15 const data = await strapiRes.json();
16
17 if (!strapiRes.ok) {
18 return NextResponse.json({ message: data.error?.message }, { status: 401 });
19 }
20
21 const cookieStore = await cookies();
22 cookieStore.set("auth_token", data.jwt, {
23 httpOnly: true,
24 secure: process.env.NODE_ENV === "production",
25 sameSite: "lax",
26 maxAge: 60 * 60 * 24 * 7,
27 path: "/",
28 });
29
30 return NextResponse.json({ user: data.user });
31}The client-side login form posts to /api/auth/login (not directly to Strapi). The Strapi login endpoint uses identifier as the field name, not email. It accepts either email or username.
A server component reads the httpOnly cookie and fetches the vendor's own products:
1import { cookies } from "next/headers";
2import { redirect } from "next/navigation";
3
4const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
5
6export default async function DashboardPage() {
7 const cookieStore = await cookies();
8 const token = cookieStore.get("auth_token")?.value;
9
10 if (!token) redirect("/login");
11
12 // Get the current user
13 const userRes = await fetch(`${STRAPI_URL}/api/users/me`, {
14 headers: { Authorization: `Bearer ${token}` },
15 cache: "no-store",
16 });
17 const user = await userRes.json();
18
19 // Fetch only this vendor's products
20 const productsRes = await fetch(
21 `${STRAPI_URL}/api/products?filters[vendor][user][id][$eq]=${user.id}&populate[images][fields][0]=url`,
22 { headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }
23 );
24 const { data: products } = await productsRes.json();
25
26 return (
27 <main>
28 <h1>Your Products</h1>
29 {products.map((p: any) => (
30 <div key={p.documentId}>{p.name} — ${p.price}</div>
31 ))}
32 </main>
33 );
34}To create a new product, the client sends a POST /api/products with the Authorization: Bearer {jwt} header and a JSON body containing name, price, and description. Ownership should be enforced server-side rather than relying on client-supplied data.
For image uploads, Strapi 5 requires a two-step process: first upload the file via POST /api/upload (which returns a numeric file id), then reference that ID in the product's images array when creating or updating the entry. You cannot upload files and create entries in a single request. Here's what that looks like in practice:
1// Step 1: Upload the image
2const form = new FormData();
3form.append("files", imageFile, "product-photo.jpg");
4
5const uploadRes = await fetch(`${STRAPI_URL}/api/upload`, {
6 method: "POST",
7 headers: { Authorization: `Bearer ${token}` },
8 body: form,
9});
10const [uploadedFile] = await uploadRes.json();
11
12// Step 2: Create the product referencing the uploaded file's numeric id
13const productRes = await fetch(`${STRAPI_URL}/api/products`, {
14 method: "POST",
15 headers: {
16 "Content-Type": "application/json",
17 Authorization: `Bearer ${token}`,
18 },
19 body: JSON.stringify({
20 data: {
21 name: "New Product",
22 price: 29.99,
23 images: [uploadedFile.id], // numeric id, not documentId
24 },
25 }),
26});Note that the images array uses the numeric id from the upload response, not documentId. The upload API's refId parameter is used to identify the entry the file(s) will be linked to.
api::[content-type-name].[middleware-filename-without-extension]. If your file is src/api/product/middlewares/is-vendor-owner.js, the route config string must be "api::product.is-vendor-owner". Make sure both parts of the middleware reference are spelled correctly and verify the name against the registered middlewares list. populate parameter (for example, populate=* or an explicit list) in the request URL to be returned in REST responses. If your product response shows vendor: null even though the relation exists in the database, add populate=vendor (or a more specific field selection like populate[vendor][fields][0]=displayName) to the query string. /api/auth/local/register endpoint assigns the default authenticated access level. Assigning the Vendor role requires a separate admin-level API call (PUT /api/users/{userId} with the role's numeric ID). In production, wrap both steps in a custom registration controller so new vendors get the correct role automatically. documentId in the marketplace flow, while numeric id values still appear in some responses and upload-related examples. Mixing them carelessly is what usually causes unexpected results. One exception called out earlier is the upload API, which uses numeric id values.Vendor isolation in a multi-vendor marketplace doesn't require a separate database per tenant. Enforcing vendor isolation at the API level is a key part of the architecture.
From this foundation, you can build different marketplace shapes, from handmade goods to B2B parts to digital downloads. The content model adapts. The ownership pattern stays the same. A few directions worth exploring next:
transfer calls on order completion, splitting the payment between the platform and the vendor. For deeper customization of middlewares, routes, and the Document Service API, the Strapi documentation covers the main documented extension points.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.