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:
- You will build a multi-vendor marketplace where vendors sign up, list their own products, and manage inventory through an authenticated dashboard, while customers browse a public storefront.
- The stack pairs a Strapi 5 backend (content modeling, JWT auth, and the Users and Permissions plugin) with a Next.js 16 App Router frontend for server-rendered pages and protected routes.
- The central technique is a route middleware that enforces vendor-scoped writes so one vendor can never edit another's products, all without spinning up a separate database per tenant.
- Along the way, you'll learn content modeling with relations, role configuration for vendors and customers, ownership middleware implementation, and building server-rendered storefronts with authenticated dashboards.
Prerequisites and Project Setup
Before starting, make sure you have:
- Node.js v20, v22, or v24 LTS (Strapi 5 requires one of these)
- npm or pnpm
- Basic familiarity with Next.js 16 App Router and TypeScript
Scaffold the Strapi backend first. If you've worked through a Strapi 5 guide project before, this will feel familiar:
npx 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:
npx 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.
How to Model Marketplace Data in Strapi
Marketplace logic lives in your content types. Get the relations right here, and the rest of the build is plumbing.
Create the Vendor Collection Type
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 todisplayName): 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 the Product Collection Type
Create another Collection Type named Product with these fields:
name(string, required)slug(UID, attached toname)description(rich text, blocks editor): returns structured JSON rather than a markdown stringprice(decimal)inventory(integer)images(media, multiple): allows several product photosvendor(relation, many-to-one to Vendor): many products belong to one vendorcategory(relation, many-to-one to Category): many products belong to one category
Enable 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.
Add Categories and Verify the Relations
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:
"vendor": {
"type": "relation",
"relation": "manyToOne",
"target": "api::vendor.vendor",
"inversedBy": "products"
}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.
How to Configure Vendor and Customer Roles
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.
Add the Vendor and Customer Roles
Navigate to Settings > Users & Permissions > Roles and click Add Role.
Create the Vendor role with these permissions:
- Product: enable
find,findOne(read access), pluscreate,update,delete(write access, scoped by the middleware you'll add next) - Category: enable
findandfindOne - Vendor: enable
find(so vendors can retrieve their own profile)
Create the Customer role:
- Product:
findandfindOne - Category:
findandfindOne - Vendor:
findandfindOne - No write access on Product
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.
Register a New Vendor Through the API
New users register through Strapi's built-in endpoint:
curl -X POST http://localhost:1337/api/auth/local/register \
-H "Content-Type: application/json" \
-d '{
"username": "acme-goods",
"email": "vendor@acme.com",
"password": "SecurePass123"
}'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):
curl -X PUT http://localhost:1337/api/users/{userId} \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ADMIN_JWT_HERE" \
-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.
Create the Matching Vendor Profile
After registration, create the Vendor profile linked to the new user account:
curl -X POST http://localhost:1337/api/vendors \
-H "Content-Type: application/json" \
-H "Authorization: Bearer VENDOR_JWT_HERE" \
-d '{
"data": {
"displayName": "Acme Goods",
"bio": "Handcrafted artisan products",
"user": "USER_DOCUMENT_ID_HERE"
}
}'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.
How to Scope Products to Their Owning Vendor
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.
Generate an is-vendor-owner Middleware
Run the Strapi CLI generator from your project root:
npm 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.
Write the Ownership Check
Replace the generated placeholder with this implementation:
"use strict";
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
const user = ctx.state.user;
if (!user) {
return ctx.unauthorized("You must be logged in.");
}
// Look up the Vendor profile linked to this user
const vendors = await strapi.documents("api::vendor.vendor").findMany({
filters: { user: { id: user.id } },
limit: 1,
});
const vendor = vendors[0];
if (!vendor) {
return ctx.unauthorized("No vendor profile found for this user.");
}
const entryId = ctx.params.id;
if (entryId) {
// UPDATE or DELETE: verify the target product belongs to this vendor
const product = await strapi.documents("api::product.product").findOne(
entryId,
{ populate: "vendor" }
);
if (!product || product.vendor.documentId !== vendor.documentId) {
return ctx.unauthorized("This action is unauthorized.");
}
} else {
// CREATE: force the vendor field to the authenticated vendor's documentId
ctx.request.body.data.vendor = vendor.documentId;
}
return next();
};
};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.
Wire the Middleware Into the Product Routes
Open src/api/product/routes/product.js and replace the default export:
const { createCoreRouter } = require("@strapi/strapi").factories;
module.exports = createCoreRouter("api::product.product", {
config: {
create: {
middlewares: ["api::product.is-vendor-owner"],
},
update: {
middlewares: ["api::product.is-vendor-owner"],
},
delete: {
middlewares: ["api::product.is-vendor-owner"],
},
},
});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:
# Log in as Vendor A
curl -X POST http://localhost:1337/api/auth/local \
-H "Content-Type: application/json" \
-d '{"identifier": "vendor-a@example.com", "password": "SecurePass123"}'
# Try to update Vendor B's product (should return 401)
curl -X PUT http://localhost:1337/api/products/VENDOR_B_PRODUCT_DOCUMENT_ID \
-H "Content-Type: application/json" \
-H "Authorization: Bearer VENDOR_A_JWT" \
-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.
How to Build the Public Storefront in Next.js 16
With the backend locked down, the Next.js 16 side stays simple: server components fetch from the public REST endpoints.
Fetch the Product Catalog Server-Side
Create app/products/page.tsx as a server component:
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
async function getProducts() {
const res = await fetch(
`${STRAPI_URL}/api/products?populate[images][fields][0]=url&populate[images][fields][1]=alternativeText&populate[vendor][fields][0]=displayName&populate[vendor][fields][1]=slug`,
{ next: { revalidate: 60 } }
);
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
}
export default async function ProductsPage() {
const { data: products } = await getProducts();
return (
<main>
<h1>All Products</h1>
<div className="grid grid-cols-3 gap-4">
{products.map((product: any) => (
<div key={product.documentId} className="border p-4 rounded">
<h2>{product.name}</h2>
<p>${product.price}</p>
{product.vendor && <p>by {product.vendor.displayName}</p>}
</div>
))}
</div>
</main>
);
}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.
Build the Per-Vendor Storefront Page
Create the dynamic route at app/vendors/[slug]/page.tsx:
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function VendorPage({ params }: PageProps) {
const { slug } = await params;
const res = await fetch(
`${STRAPI_URL}/api/vendors?filters[slug][$eq]=${slug}&populate[products][populate][images][fields][0]=url&populate[logo][fields][0]=url`,
{ next: { revalidate: 60 } }
);
const { data } = await res.json();
const vendor = data[0];
if (!vendor) return <p>Vendor not found</p>;
return (
<main>
<h1>{vendor.displayName}</h1>
<p>{vendor.bio}</p>
<h2>Products</h2>
{vendor.products?.map((product: any) => (
<div key={product.documentId}>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</main>
);
}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.
How to Build the Vendor Dashboard
Vendors need a place to log in, see their own products, and add new ones. Two routes cover both.
Log in and Store the JWT
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.
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
export async function POST(request: NextRequest) {
const { identifier, password } = await request.json();
const strapiRes = await fetch(`${STRAPI_URL}/api/auth/local`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier, password }),
});
const data = await strapiRes.json();
if (!strapiRes.ok) {
return NextResponse.json({ message: data.error?.message }, { status: 401 });
}
const cookieStore = await cookies();
cookieStore.set("auth_token", data.jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
path: "/",
});
return NextResponse.json({ user: data.user });
}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.
List and Create Vendor Products
A server component reads the httpOnly cookie and fetches the vendor's own products:
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
export default async function DashboardPage() {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
if (!token) redirect("/login");
// Get the current user
const userRes = await fetch(`${STRAPI_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const user = await userRes.json();
// Fetch only this vendor's products
const productsRes = await fetch(
`${STRAPI_URL}/api/products?filters[vendor][user][id][$eq]=${user.id}&populate[images][fields][0]=url`,
{ headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }
);
const { data: products } = await productsRes.json();
return (
<main>
<h1>Your Products</h1>
{products.map((p: any) => (
<div key={p.documentId}>{p.name} — ${p.price}</div>
))}
</main>
);
}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:
// Step 1: Upload the image
const form = new FormData();
form.append("files", imageFile, "product-photo.jpg");
const uploadRes = await fetch(`${STRAPI_URL}/api/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: form,
});
const [uploadedFile] = await uploadRes.json();
// Step 2: Create the product referencing the uploaded file's numeric id
const productRes = await fetch(`${STRAPI_URL}/api/products`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: {
name: "New Product",
price: 29.99,
images: [uploadedFile.id], // numeric id, not documentId
},
}),
});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.
Common Issues and Troubleshooting
- Middleware not firing. This is almost always a naming mismatch between the middleware file name and the string in your route config. The pattern is
api::[content-type-name].[middleware-filename-without-extension]. If your file issrc/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 returns empty relations. Strapi 5 returns only scalar fields by default. Relation and media fields are not populated by default and require a
populateparameter (for example,populate=*or an explicit list) in the request URL to be returned in REST responses. If your product response showsvendor: nulleven though the relation exists in the database, addpopulate=vendor(or a more specific field selection likepopulate[vendor][fields][0]=displayName) to the query string. - Registration returns authenticated access instead of Vendor. The default
/api/auth/local/registerendpoint 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. - Identifier confusion. In this article, content entries are created and linked with
documentIdin the marketplace flow, while numericidvalues 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 numericidvalues.
Where to Take Your Marketplace Next
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:
- Payments. Integrate Stripe Connect for split payouts to vendors. This requires a custom Strapi controller that creates Stripe
transfercalls on order completion, splitting the payment between the platform and the vendor. - Search. Add Meilisearch or Algolia for full-text product search. Strapi has community plugins for both that automatically sync content on publish. This is much faster than filtering through the REST API for large catalogs.
- Deployment. Deploy Strapi to Strapi Cloud and Next.js 16 to Vercel for the fastest path to production. You can also self-host both behind a reverse proxy. Review the available Strapi deployment options for a comparison of platforms.
For deeper customization of middlewares, routes, and the Document Service API, the Strapi documentation covers the main documented extension points.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.