Building a wedding vendor marketplace usually means juggling content modeling, search, media handling, and user-generated reviews without turning the project into a maintenance headache. This tutorial shows how to use Strapi as a headless CMS with Next.js so you can build a directory that handles profiles, media, and authenticated reviews without writing a single backend route.
By the end of this tutorial, you'll have a working wedding vendor directory with category filtering, city-based search, vendor profile pages with portfolio galleries, and authenticated reviews. The stack: Strapi 5 for the backend, Next.js 16 with the App Router for the frontend, and JSON Web Token (JWT) auth via the Users and Permissions feature for review submission.
In brief:
- Define
Vendor,Category, andReviewContent-Types with relations and media fields in Strapi 5 - Build a filterable vendor directory and detail pages with Next.js 16 Server Components
- Wire up JWT authentication so registered users can submit vendor reviews
- Deploy the backend to Strapi Cloud and the frontend to Vercel
Prerequisites
Before you start, confirm you have the following installed:
- Node.js v20 or later; for Strapi 5, supported LTS versions currently include v20, v22, and v24
- npm or pnpm
- Basic familiarity with React Server Components and the Strapi Admin Panel
Here's the build order so you can orient yourself:
- Model in Strapi: Define
Vendor,Category, andReviewContent-Types with relations and media fields - Fetch in Next.js: Build listing pages, detail pages, and a filter UI powered by the REST API
- Layer in auth: Register users, log them in, and let them post reviews
Project Setup
This section gets both servers running: Strapi on localhost:1337 and Next.js on localhost:3000. Keep two terminal windows open from here on.
Spin Up the Strapi Backend
Run the following command to scaffold a new Strapi project:
npx create-strapi@latest wedding-marketplace-apiThe interactive CLI prompts you for TypeScript or JavaScript, your preferred package manager, and your database. SQLite works for local development; you can switch to PostgreSQL for production. Once the prompts finish, start the dev server:
cd wedding-marketplace-api
npm run developOpen http://localhost:1337/admin and create your first admin user. The Content-Type Builder is where all Content-Type modeling happens, so it helps to keep this tab open.
Create the Next.js Frontend
Scaffold the project with the App Router, TypeScript, and Tailwind enabled:
npx create-next-app@latest wedding-marketplace-webThe project tree includes the standard Next.js directories along with any additional folders you create for your application code:
wedding-marketplace-web/
├── app/
│ ├── layout.tsx
│ └── page.tsx
├── components/
├── lib/
├── public/
├── .env.local
└── next.config.jsCreate a .env.local file with two variables:
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=Leave the token blank for now. You'll generate it after configuring permissions. Confirm the dev server runs with the following commands, then visit localhost:3000 to verify:
cd wedding-marketplace-web
npm run devModel the Marketplace in Strapi
The data model has three Collection Types: Vendor, Category, and Review. A vendor belongs to one category (many-to-one), and a vendor has many reviews (one-to-many). Vendors also carry media fields for a cover image and a gallery of portfolio images.
Strapi 5 introduces documentId (a 24-character alphanumeric string) as the primary identifier for documents, although entries might still include a numeric id field for compatibility. All API calls reference documentId, and the response format is flat: fields sit directly on the data object with no .attributes wrapper.
Build the Vendor Content-Type
The Vendor Content-Type is the core data model. It holds everything a couple needs to evaluate a vendor: name, location, price range, portfolio images, and a booking contact.
Open the Content-Type Builder from the Admin Panel and create a new Collection Type called Vendor. Add the following fields:
name(Text, required, unique). The unique constraint prevents duplicate vendor listings from cluttering search results.slug(UID, attached toname). The UID field type can generate a URL-safe string based on thenamefield, but this behavior depends on how the entry is created or updated and may require additional implementation outside the admin UI. This slug becomes the basis for clean URLs like/vendors/blossom-photography.city(Text).description(Rich Text, Blocks editor). Choose the Blocks editor rather than the Markdown editor.priceRange(Enumeration:$,$$).coverImage(Media, single image). This serves as the card thumbnail on the directory listing page.portfolio(Media, multiple images). A multi-image field for showcasing past work on the vendor's detail page.isVerified(Boolean, default false). Useful for distinguishing vendors who have completed an admin review process.bookingEmail(Email). The email address where couples can reach the vendor.
After saving, verify the generated schema at src/api/vendor/content-types/vendor/schema.json:
{
"kind": "collectionType",
"collectionName": "vendors",
"info": {
"singularName": "vendor",
"pluralName": "vendors",
"displayName": "Vendor"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": { "type": "string", "required": true, "unique": true },
"slug": { "type": "uid", "targetField": "name" },
"city": { "type": "string" },
"description": { "type": "blocks" },
"priceRange": {
"type": "enumeration",
"enum": ["$", "$$"]
},
"coverImage": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"portfolio": {
"type": "media",
"multiple": true,
"allowedTypes": ["images"]
},
"isVerified": { "type": "boolean", "default": false },
"bookingEmail": { "type": "email" }
}
}Click Save, and Strapi restarts automatically. You'll see the dev server rebuild in your terminal. Once it's back up, visit the Content Manager to confirm the Vendor collection type appears in the sidebar. If it doesn't, a quick browser refresh usually fixes it
Create Category and Review Content-Types with Relations
Create a Category Collection Type with two fields: name (Text, required) and slug (UID, attached to name).
Now add a relation on Vendor. Open Vendor in the Content-Type Builder, add a Relation field, select Category as the target, and choose the many-to-one icon. Name the field category on the Vendor side and vendors on the Category side.
Next, create a Review Collection Type: rating (Number, integer 1–5), comment (Text, long text), and authorName (Text). Add a many-to-one relation from Review to Vendor, naming the field vendor on the Review side and reviews on the Vendor side.
Seed some data from the Content Manager. Create categories like photographer, florist, caterer, venue, and band. Then add a couple of vendor records, for example "Blossom Photography" under photographer and "Savor Catering" under caterer, each with a cover image and a couple of portfolio images so the API has data to return. Remember to publish each entry after creating it. Unpublished entries do not appear in API responses by default.
In Strapi 5, nested relations and media require explicit deep populate. The populate plan matters in the next section.
Configure Roles and Permissions
Navigate to Settings → Users & Permissions Plugin → Roles → Public. Enable find and findOne on Vendor, Category, and Review. Leave create and update disabled on the Public role for Review. Reviews require an authenticated user, which you'll wire up later. Save the role. A 403 on any API request usually means the Public role is missing the find or findOne permission for that content type.
Expose Marketplace Data Through the REST API
The Strapi REST API returns only top-level scalar fields by default; relations, media, and components all require explicit populate parameters. The queries below power the vendor list, vendor detail, and filter pages on the frontend.
Fetch Vendors with Deep Populate
By default, the Strapi REST API returns only top-level scalar fields. Relations, media, and components are excluded unless you explicitly populate them. Forgetting a populate parameter is the most common reason for empty relation fields in API responses.
For the vendor list page, fetch categories and cover images:
GET /api/vendors?populate[0]=category&populate[1]=coverImageFor a single vendor profile with portfolio and reviews:
GET /api/vendors/:documentId?populate=portfolio&populate[category]=true&populate[reviews]=trueThe response format is flat. Fields sit directly on the data object with no .attributes wrapper:
{
"data": {
"id": 2,
"documentId": "hgv1vny5cebq2l3czil1rpb3",
"name": "Blossom Photography",
"slug": "blossom-photography",
"city": "Brooklyn",
"priceRange": "$",
"category": {
"id": 1,
"documentId": "a1b2c3d4e5f6g7h8i9j0klmn",
"name": "Photographer",
"slug": "photographer"
},
"coverImage": {
"url": "/uploads/blossom_cover_abc123.jpg",
"formats": { "thumbnail": { "url": "/uploads/thumbnail_blossom_cover_abc123.jpg" } }
}
}
}The documentId is the canonical identifier. Use it for all lookups and relation assignments.
Filter and Paginate Listings
Strapi's REST API supports filtering, pagination, and sorting through query parameters. To filter by category slug, append the following:
?filters[category][slug][$eq]=photographerTo filter by city, use the same pattern on the city field:
?filters[city][$eq]=BrooklynYou can combine filters by appending multiple filters parameters to the same query string. Strapi applies them with AND logic by default, so a request with both filters[category][slug][$eq]=photographer and filters[city][$eq]=Brooklyn returns only photographers located in Brooklyn. For OR logic, use the $or operator at the top level of the filters object.
Pagination and sorting each take their own parameters. Add pagination[page] and pagination[pageSize] for paged results, and sort for ordering:
?pagination[page]=1&pagination[pageSize]=12&sort=name:ascHere's a combined query the frontend will use to fetch a filtered, paginated, and sorted vendor list:
GET /api/vendors?populate[category]=true&populate[coverImage]=true&filters[category][slug][$eq]=photographer&filters[city][$eq]=Brooklyn&pagination[page]=1&pagination[pageSize]=12&sort=name:ascGenerate an API Token for the Frontend
Go to Settings → Global settings → API Tokens → Create new API Token. Set the token type to Read-only (sufficient for a public directory), name it nextjs-frontend, and choose a 7-day duration. Shorter durations force regular rotation, which limits the blast radius if a token leaks. For a production deployment, rotate tokens on a schedule and store them in your hosting provider's secrets manager rather than committing them to version control.
Copy the token immediately. It is shown only once unless an encryption key (admin.secrets.encryptionKey) is configured. Paste it into .env.local on the Next.js side as STRAPI_API_TOKEN. Keep in mind that Content API tokens and admin tokens are strictly separated: Content API tokens are rejected on admin routes, and admin tokens are rejected on Content API routes.
Build the Next.js Frontend
This section covers four pieces: env wiring, the vendor list page, the vendor detail page, and a search/filter UI.
Configure Environment Variables and a Fetch Helper
Confirm your .env.local values are set. Then create a fetch helper at lib/strapi.ts:
// lib/strapi.ts
const STRAPI_URL = process.env.STRAPI_URL!;
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN!;
export type StrapiResponse<T> = {
data: T;
meta: { pagination?: { page: number; pageSize: number; pageCount: number; total: number } };
};
export async function fetchAPI<T>(path: string): Promise<StrapiResponse<T>> {
const res = await fetch(`${STRAPI_URL}${path}`, {
headers: { Authorization: `Bearer ${STRAPI_API_TOKEN}` },
});
if (!res.ok) {
throw new Error(`Strapi fetch failed: ${path} returned ${res.status}`);
}
return res.json();
}Define your TypeScript types for Vendor, Category, and Review in a separate lib/types.ts file. Environment variables can be read directly in Server Components, while client-side access is limited to variables prefixed for the browser, so any client-side fetch has to go through a server action or route handler.
Render the Vendor Directory Page
Create app/vendors/page.tsx as an async Server Component. Page props in Next.js 16 expose searchParams as a Promise, so you must await it before reading:
// app/vendors/page.tsx
import { fetchAPI, type StrapiResponse } from '@/lib/strapi';
import VendorCard from '@/components/VendorCard';
import type { Vendor } from '@/lib/types';
export default async function VendorsPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { category = '', city = '' } = await searchParams;
let query = 'populate[category]=true&populate[coverImage]=true&pagination[pageSize]=12';
if (category) query += `&filters[category][slug][$eq]=${category}`;
if (city) query += `&filters[city][$eq]=${city}`;
const { data: vendors } = await fetchAPI<Vendor[]>(`/api/vendors?${query}`);
return (
<section className="grid grid-cols-1 md:grid-cols-3 gap-6 p-8">
{vendors.map((vendor) => (
<VendorCard key={vendor.documentId} vendor={vendor} />
))}
</section>
);
}The VendorCard component shows the cover image, name, category, city, and a price range badge. Here's a minimal implementation using next/image with the Strapi-served URL:
// components/VendorCard.tsx
import Image from 'next/image';
import Link from 'next/link';
import type { Vendor } from '@/lib/types';
const STRAPI_URL = process.env.STRAPI_URL!;
export default function VendorCard({ vendor }: { vendor: Vendor }) {
const imageUrl = vendor.coverImage?.url
? `${STRAPI_URL}${vendor.coverImage.url}`
: '/placeholder.jpg';
return (
<Link href={`/vendors/${vendor.slug}`} className="block rounded-lg border hover:shadow-md transition-shadow">
<div className="relative h-48 w-full">
<Image src={imageUrl} alt={vendor.name} fill className="object-cover rounded-t-lg" />
</div>
<div className="p-4">
<h2 className="text-lg font-semibold">{vendor.name}</h2>
<p className="text-sm text-gray-600">
{vendor.category?.name} · {vendor.city}
</p>
<span className="inline-block mt-2 px-2 py-1 text-xs bg-gray-100 rounded">
{vendor.priceRange}
</span>
</div>
</Link>
);
}To allow next/image to optimize images served from your Strapi instance, add a remotePatterns entry to your Next.js config. During local development, include port: '1337' in the local pattern, and add your production Strapi domain when you deploy:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '1337',
pathname: '/uploads/**',
},
],
},
};Create a Vendor Detail Page with Async Params
Create app/vendors/[slug]/page.tsx. In Next.js 16, params is a Promise and must be awaited before use:
// app/vendors/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { fetchAPI } from '@/lib/strapi';
import BlockRendererClient from '@/components/BlockRendererClient';
import type { Vendor } from '@/lib/types';
export default async function VendorPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { data } = await fetchAPI<Vendor[]>(
`/api/vendors?filters[slug][$eq]=${slug}&populate[portfolio]=true&populate[category]=true&populate[reviews]=true`
);
if (!data.length) notFound();
const vendor = data[0];
return (
<article>
<h1>{vendor.name}</h1>
<p>{vendor.category?.name} · {vendor.city} · {vendor.priceRange}</p>
<BlockRendererClient content={vendor.description} />
{/* Portfolio gallery and reviews list */}
</article>
);
}Implement Vendor Reviews with User Authentication
This section adds JWT-based authentication so that anyone can browse the directory, but only registered users can post reviews. Strapi's Users and Permissions feature handles registration, login, and role-based access control out of the box.
Enable Users and Permissions Registration and Login
The Users and Permissions feature ships with Strapi 5. Two endpoints handle registration and login, and both return { jwt, user } on success:
POST /api/auth/local/register(body:username,email,password)POST /api/auth/local(body:identifier,password)
Before wiring up the frontend, configure the Authenticated role's permissions. Navigate to Settings → Users & Permissions Plugin → Roles → Authenticated and enable the create permission on Review. Leave update and delete disabled unless you want users to edit or remove their own reviews later. This setup means any logged-in user can post a review, but only admins can modify or delete them. Build server actions in app/actions/auth.ts:
// app/actions/auth.ts
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function loginUser(formData: FormData) {
const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: formData.get('identifier'),
password: formData.get('password'),
}),
});
if (!res.ok) return { error: 'Invalid credentials' };
const { jwt } = await res.json();
const cookieStore = await cookies();
cookieStore.set('strapi_jwt', jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
redirect('/vendors');
}
export async function registerUser(formData: FormData) {
const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: formData.get('username'),
email: formData.get('email'),
password: formData.get('password'),
}),
});
if (!res.ok) return { error: 'Registration failed' };
const { jwt } = await res.json();
const cookieStore = await cookies();
cookieStore.set('strapi_jwt', jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
redirect('/vendors');
}The cookies() function is async in Next.js 16 and must be awaited, which is why both actions use await cookies(). Strapi 5 also supports a session management mode: you can set jwtManagement: 'refresh' in the Users and Permissions config for shorter-lived access tokens plus refresh tokens.
When this mode is active, login and register responses include both a jwt and a refreshToken, and additional endpoints (POST /api/auth/refresh and POST /api/auth/logout) become available.
Submit a Review from the Frontend
A server action reads the JWT from cookies and posts to Strapi's Review endpoint:
// app/vendors/[slug]/actions.ts
'use server';
import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';
export async function submitReview(vendorDocumentId: string, vendorSlug: string, formData: FormData) {
const cookieStore = await cookies();
const jwt = cookieStore.get('strapi_jwt')?.value;
if (!jwt) return { error: 'Not authenticated' };
const res = await fetch(`${process.env.STRAPI_URL}/api/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({
data: {
rating: Number(formData.get('rating')),
comment: formData.get('comment'),
vendor: vendorDocumentId,
},
}),
});
if (res.status === 403) {
cookieStore.delete('strapi_jwt');
return { error: 'Session expired. Please log in again.' };
}
if (!res.ok) return { error: 'Failed to submit review' };
revalidatePath(`/vendors/${vendorSlug}`);
return { success: true };
}Relations in Strapi 5 are managed via relation IDs in REST payloads, while documents themselves are identified by documentId in API calls.
The revalidatePath call invalidates the cached data for this specific vendor page so the new review appears on the next visit without a full rebuild. Next.js recommends preferring tag-based revalidation when possible because it is more precise, but path-based revalidation works well when you know the exact URL that needs refreshing.
Here's a client component for the review form that calls the server action:
// components/ReviewForm.tsx
'use client';
import { useActionState } from 'react';
import { submitReview } from '@/app/vendors/[slug]/actions';
export default function ReviewForm({ vendorDocumentId, vendorSlug }: { vendorDocumentId: string; vendorSlug: string }) {
const submitWithIds = async (_prevState: any, formData: FormData) => {
return submitReview(vendorDocumentId, vendorSlug, formData);
};
const [state, formAction, isPending] = useActionState(submitWithIds, {});
return (
<form action={formAction} className="space-y-4 mt-8">
<select name="rating" required className="block w-full border rounded p-2">
<option value="">Select rating</option>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={n}>{n} star{n > 1 ? 's' : ''}</option>
))}
</select>
<textarea name="comment" placeholder="Share your experience..." required className="block w-full border rounded p-2" rows={4} />
{state?.error && <p className="text-red-600">{state.error}</p>}
<button type="submit" disabled={isPending} className="px-4 py-2 bg-blue-600 text-white rounded">
{isPending ? 'Submitting...' : 'Submit Review'}
</button>
</form>
);
}The useActionState hook tracks the submission lifecycle, so the form can display validation errors from the server action and disable the button while the request is in flight. Import this component into the vendor detail page and pass the vendor's documentId and slug as props.
Deploy Your Wedding Vendor Marketplace
Once both projects are working locally, you can push to production with a few configuration changes.
Backend: Push the Strapi project to a GitHub repo and deploy to Strapi Cloud or self-host on a Postgres-backed VPS using the database configuration docs. Before deploying, switch the database from SQLite to a production-grade database such as PostgreSQL.
Set the DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USERNAME, and DATABASE_PASSWORD environment variables on your hosting provider. Also set the JWT_SECRET and APP_KEYS secrets that Strapi generated during project creation.
Frontend: Deploy the Next.js app to Vercel and set STRAPI_URL and STRAPI_API_TOKEN as environment variables. Point STRAPI_URL to your production Strapi instance's public URL, not localhost.
Here are three extensions worth building next:
- Stripe Connect for vendor payouts and booking deposits.
- Internationalization via Strapi's i18n feature for multi-region marketplaces.
- Webhooks to trigger email notifications when new reviews are posted.
For deeper modeling work, check the Strapi relations docs and Media Library feature page.
How Strapi Powered This
You've built a wedding vendor marketplace with category filtering, portfolio galleries, and authenticated reviews, all without writing a custom backend. Strapi handled the data layer and access control while Next.js handled rendering and routing.
- The Content-Type Builder modeled vendors, categories, and reviews without writing a schema file;
- Strapi 5's flat response format simplified frontend data access by removing the
.attributeswrapper; - REST API filters and pagination handled category and city-based vendor queries;
- The Users and Permissions feature provided JWT-based auth for review submission with zero custom middleware;
- Media fields and the Upload API managed cover images and portfolio galleries with CDN-ready provider support.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.