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:
Vendor, Category, and Review Content-Types with relations and media fields in Strapi 5 Before you start, confirm you have the following installed:
Here's the build order so you can orient yourself:
Vendor, Category, and Review Content-Types with relations and media fields This section gets both servers running: Strapi on localhost:1337 and Next.js on localhost:3000. Keep two terminal windows open from here on.
Run the following command to scaffold a new Strapi project:
1npx 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:
1cd wedding-marketplace-api
2npm 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.
Scaffold the project with the App Router, TypeScript, and Tailwind enabled:
1npx 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:
1wedding-marketplace-web/
2├── app/
3│ ├── layout.tsx
4│ └── page.tsx
5├── components/
6├── lib/
7├── public/
8├── .env.local
9└── next.config.jsCreate a .env.local file with two variables:
1STRAPI_URL=http://localhost:1337
2STRAPI_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:
1cd wedding-marketplace-web
2npm run devThe 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.
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 to name). The UID field type can generate a URL-safe string based on the name field, 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:
1{
2 "kind": "collectionType",
3 "collectionName": "vendors",
4 "info": {
5 "singularName": "vendor",
6 "pluralName": "vendors",
7 "displayName": "Vendor"
8 },
9 "options": {
10 "draftAndPublish": true
11 },
12 "attributes": {
13 "name": { "type": "string", "required": true, "unique": true },
14 "slug": { "type": "uid", "targetField": "name" },
15 "city": { "type": "string" },
16 "description": { "type": "blocks" },
17 "priceRange": {
18 "type": "enumeration",
19 "enum": ["$", "$$"]
20 },
21 "coverImage": {
22 "type": "media",
23 "multiple": false,
24 "allowedTypes": ["images"]
25 },
26 "portfolio": {
27 "type": "media",
28 "multiple": true,
29 "allowedTypes": ["images"]
30 },
31 "isVerified": { "type": "boolean", "default": false },
32 "bookingEmail": { "type": "email" }
33 }
34}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 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.
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.
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.
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:
1GET /api/vendors?populate[0]=category&populate[1]=coverImageFor a single vendor profile with portfolio and reviews:
1GET /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:
1{
2 "data": {
3 "id": 2,
4 "documentId": "hgv1vny5cebq2l3czil1rpb3",
5 "name": "Blossom Photography",
6 "slug": "blossom-photography",
7 "city": "Brooklyn",
8 "priceRange": "$",
9 "category": {
10 "id": 1,
11 "documentId": "a1b2c3d4e5f6g7h8i9j0klmn",
12 "name": "Photographer",
13 "slug": "photographer"
14 },
15 "coverImage": {
16 "url": "/uploads/blossom_cover_abc123.jpg",
17 "formats": { "thumbnail": { "url": "/uploads/thumbnail_blossom_cover_abc123.jpg" } }
18 }
19 }
20}The documentId is the canonical identifier. Use it for all lookups and relation assignments.
Strapi's REST API supports filtering, pagination, and sorting through query parameters. To filter by category slug, append the following:
1?filters[category][slug][$eq]=photographerTo filter by city, use the same pattern on the city field:
1?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:
1?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:
1GET /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.
This section covers four pieces: env wiring, the vendor list page, the vendor detail page, and a search/filter UI.
Confirm your .env.local values are set. Then create a fetch helper at lib/strapi.ts:
1// lib/strapi.ts
2const STRAPI_URL = process.env.STRAPI_URL!;
3const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN!;
4
5export type StrapiResponse<T> = {
6 data: T;
7 meta: { pagination?: { page: number; pageSize: number; pageCount: number; total: number } };
8};
9
10export async function fetchAPI<T>(path: string): Promise<StrapiResponse<T>> {
11 const res = await fetch(`${STRAPI_URL}${path}`, {
12 headers: { Authorization: `Bearer ${STRAPI_API_TOKEN}` },
13 });
14
15 if (!res.ok) {
16 throw new Error(`Strapi fetch failed: ${path} returned ${res.status}`);
17 }
18
19 return res.json();
20}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.
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:
1// app/vendors/page.tsx
2import { fetchAPI, type StrapiResponse } from '@/lib/strapi';
3import VendorCard from '@/components/VendorCard';
4import type { Vendor } from '@/lib/types';
5
6export default async function VendorsPage({
7 searchParams,
8}: {
9 searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
10}) {
11 const { category = '', city = '' } = await searchParams;
12
13 let query = 'populate[category]=true&populate[coverImage]=true&pagination[pageSize]=12';
14 if (category) query += `&filters[category][slug][$eq]=${category}`;
15 if (city) query += `&filters[city][$eq]=${city}`;
16
17 const { data: vendors } = await fetchAPI<Vendor[]>(`/api/vendors?${query}`);
18
19 return (
20 <section className="grid grid-cols-1 md:grid-cols-3 gap-6 p-8">
21 {vendors.map((vendor) => (
22 <VendorCard key={vendor.documentId} vendor={vendor} />
23 ))}
24 </section>
25 );
26}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:
1// components/VendorCard.tsx
2import Image from 'next/image';
3import Link from 'next/link';
4import type { Vendor } from '@/lib/types';
5
6const STRAPI_URL = process.env.STRAPI_URL!;
7
8export default function VendorCard({ vendor }: { vendor: Vendor }) {
9 const imageUrl = vendor.coverImage?.url
10 ? `${STRAPI_URL}${vendor.coverImage.url}`
11 : '/placeholder.jpg';
12
13 return (
14 <Link href={`/vendors/${vendor.slug}`} className="block rounded-lg border hover:shadow-md transition-shadow">
15 <div className="relative h-48 w-full">
16 <Image src={imageUrl} alt={vendor.name} fill className="object-cover rounded-t-lg" />
17 </div>
18 <div className="p-4">
19 <h2 className="text-lg font-semibold">{vendor.name}</h2>
20 <p className="text-sm text-gray-600">
21 {vendor.category?.name} · {vendor.city}
22 </p>
23 <span className="inline-block mt-2 px-2 py-1 text-xs bg-gray-100 rounded">
24 {vendor.priceRange}
25 </span>
26 </div>
27 </Link>
28 );
29}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:
1// next.config.js
2module.exports = {
3 images: {
4 remotePatterns: [
5 {
6 protocol: 'http',
7 hostname: 'localhost',
8 port: '1337',
9 pathname: '/uploads/**',
10 },
11 ],
12 },
13};Create app/vendors/[slug]/page.tsx. In Next.js 16, params is a Promise and must be awaited before use:
1// app/vendors/[slug]/page.tsx
2import { notFound } from 'next/navigation';
3import { fetchAPI } from '@/lib/strapi';
4import BlockRendererClient from '@/components/BlockRendererClient';
5import type { Vendor } from '@/lib/types';
6
7export default async function VendorPage({
8 params,
9}: {
10 params: Promise<{ slug: string }>;
11}) {
12 const { slug } = await params;
13 const { data } = await fetchAPI<Vendor[]>(
14 `/api/vendors?filters[slug][$eq]=${slug}&populate[portfolio]=true&populate[category]=true&populate[reviews]=true`
15 );
16
17 if (!data.length) notFound();
18
19 const vendor = data[0];
20
21 return (
22 <article>
23 <h1>{vendor.name}</h1>
24 <p>{vendor.category?.name} · {vendor.city} · {vendor.priceRange}</p>
25 <BlockRendererClient content={vendor.description} />
26 {/* Portfolio gallery and reviews list */}
27 </article>
28 );
29}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.
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:
1// app/actions/auth.ts
2'use server';
3
4import { cookies } from 'next/headers';
5import { redirect } from 'next/navigation';
6
7export async function loginUser(formData: FormData) {
8 const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local`, {
9 method: 'POST',
10 headers: { 'Content-Type': 'application/json' },
11 body: JSON.stringify({
12 identifier: formData.get('identifier'),
13 password: formData.get('password'),
14 }),
15 });
16
17 if (!res.ok) return { error: 'Invalid credentials' };
18
19 const { jwt } = await res.json();
20 const cookieStore = await cookies();
21 cookieStore.set('strapi_jwt', jwt, {
22 httpOnly: true,
23 secure: process.env.NODE_ENV === 'production',
24 sameSite: 'lax',
25 path: '/',
26 maxAge: 60 * 60 * 24 * 7,
27 });
28
29 redirect('/vendors');
30}
31
32export async function registerUser(formData: FormData) {
33 const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local/register`, {
34 method: 'POST',
35 headers: { 'Content-Type': 'application/json' },
36 body: JSON.stringify({
37 username: formData.get('username'),
38 email: formData.get('email'),
39 password: formData.get('password'),
40 }),
41 });
42
43 if (!res.ok) return { error: 'Registration failed' };
44
45 const { jwt } = await res.json();
46 const cookieStore = await cookies();
47 cookieStore.set('strapi_jwt', jwt, {
48 httpOnly: true,
49 secure: process.env.NODE_ENV === 'production',
50 sameSite: 'lax',
51 path: '/',
52 maxAge: 60 * 60 * 24 * 7,
53 });
54
55 redirect('/vendors');
56}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.
A server action reads the JWT from cookies and posts to Strapi's Review endpoint:
1// app/vendors/[slug]/actions.ts
2'use server';
3
4import { cookies } from 'next/headers';
5import { revalidatePath } from 'next/cache';
6
7export async function submitReview(vendorDocumentId: string, vendorSlug: string, formData: FormData) {
8 const cookieStore = await cookies();
9 const jwt = cookieStore.get('strapi_jwt')?.value;
10
11 if (!jwt) return { error: 'Not authenticated' };
12
13 const res = await fetch(`${process.env.STRAPI_URL}/api/reviews`, {
14 method: 'POST',
15 headers: {
16 'Content-Type': 'application/json',
17 Authorization: `Bearer ${jwt}`,
18 },
19 body: JSON.stringify({
20 data: {
21 rating: Number(formData.get('rating')),
22 comment: formData.get('comment'),
23 vendor: vendorDocumentId,
24 },
25 }),
26 });
27
28 if (res.status === 403) {
29 cookieStore.delete('strapi_jwt');
30 return { error: 'Session expired. Please log in again.' };
31 }
32
33 if (!res.ok) return { error: 'Failed to submit review' };
34
35 revalidatePath(`/vendors/${vendorSlug}`);
36 return { success: true };
37}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:
1// components/ReviewForm.tsx
2'use client';
3
4import { useActionState } from 'react';
5import { submitReview } from '@/app/vendors/[slug]/actions';
6
7export default function ReviewForm({ vendorDocumentId, vendorSlug }: { vendorDocumentId: string; vendorSlug: string }) {
8 const submitWithIds = async (_prevState: any, formData: FormData) => {
9 return submitReview(vendorDocumentId, vendorSlug, formData);
10 };
11
12 const [state, formAction, isPending] = useActionState(submitWithIds, {});
13
14 return (
15 <form action={formAction} className="space-y-4 mt-8">
16 <select name="rating" required className="block w-full border rounded p-2">
17 <option value="">Select rating</option>
18 {[1, 2, 3, 4, 5].map((n) => (
19 <option key={n} value={n}>{n} star{n > 1 ? 's' : ''}</option>
20 ))}
21 </select>
22 <textarea name="comment" placeholder="Share your experience..." required className="block w-full border rounded p-2" rows={4} />
23 {state?.error && <p className="text-red-600">{state.error}</p>}
24 <button type="submit" disabled={isPending} className="px-4 py-2 bg-blue-600 text-white rounded">
25 {isPending ? 'Submitting...' : 'Submit Review'}
26 </button>
27 </form>
28 );
29}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.
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:
For deeper modeling work, check the Strapi relations docs and Media Library feature page.
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.
.attributes wrapper; npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.