In this tutorial, you'll build a small but production-shaped tutor marketplace: a Strapi 5 backend that models tutors and bookings, and a Next.js 16 frontend where visitors browse tutors, view a profile, log in, and book a session. Students authenticate through Strapi's built-in Users and Permissions feature, and bookings go through a custom controller that uses the Document Service API to reject double-bookings before they hit the database.
On the Strapi side, you'll work with the v5 essentials: documentId as the canonical identifier, the flattened REST API response format, and the Document Service API that replaced the Entity Service API from v4. On the Next.js side, you'll use Server Components with async params (enforced in Next.js 16), Server Actions with httpOnly cookies for JWT storage, next/image with remotePatterns for Strapi media, and time-based revalidation for cached tutor listings.
The request flow is worth picturing before you write any code. A browser hits a Next.js Server Component, which calls the Strapi REST API server-side using STRAPI_URL; that URL never reaches the browser. Strapi returns flattened JSON, where each tutor's fields sit directly on the data object with a string documentId. When a student books, the submission runs through a Server Action instead of a client fetch. The action reads the JWT out of an httpOnly cookie and attaches it as an Authorization: Bearer header on a POST /api/bookings. On the backend, the custom booking controller runs a count() guard against existing bookings for that tutor and slot, then hands off to super.create(). The tutor's documentId travels through the form as a hidden field and lands in the relation's connect array.
In brief:
- Strapi 5 identifies content by
documentId, a stable alphanumeric string, not a numericid. - v5 REST responses are flattened, with attributes sitting directly on the
dataobject and nodata.attributeswrapper. - A custom controller using the Document Service
count()method blocks double-bookings before they reach the database. - Next.js 16 enforces async
paramsand awaitedcookies(), and stores the JWT in an httpOnly cookie through a Server Action.
What you'll learn:
- Modeling relations (tutor ⟷ bookings) in Strapi 5
schema.jsonfiles - Extending a core controller with the Document Service API
- Fetching flattened Strapi 5 REST responses from typed Server Components
- Handling login and authenticated writes with Next.js Server Actions and JWT cookies
Prerequisites
Pin these versions before you start. Strapi 5 only supports Active or Maintenance LTS Node releases, and Next.js 16 enforces async request APIs, so older tutorials with synchronous params access will break at runtime.
- Node.js v24.18.0 (current LTS, "Krypton"). Strapi 5 supports Active or Maintenance LTS only, currently v22, v24, and v26; odd-numbered releases such as v23 and v25 are not supported. Next.js 16 needs at least Node.js v20.9.
- npm v11.16.0 (bundled with Node v24.18.0)
- Strapi 5.50.0 (
@strapi/strapi@5.50.0, the latest stable release) - Next.js 16.2.11 (
next@16.2.11, the Active LTS line) with React 19.2 - Python installed locally if using the default SQLite database
- No third-party accounts or API keys; the whole project runs on localhost with SQLite
You should be comfortable with TypeScript, React Server Components, and REST basics. No prior Strapi experience needed. For a broader introduction, the beginner's guide to Next.js and Strapi covers the fundamentals.
Setting Up the Strapi Backend
Step 1: Scaffold the Strapi Project
Create the backend with the Strapi 5 CLI. The --quickstart flag from v4 is deprecated; use --non-interactive instead, which skips all prompts and applies the defaults: TypeScript, dependency installation, git init, and SQLite.
npx create-strapi@latest tutor-marketplace-backend --non-interactive
cd tutor-marketplace-backend
npm run developRunning npm run develop starts the server in watch mode and opens the Admin Panel at http://localhost:1337/admin, where you create your first admin user. The generated project follows the standard Strapi project structure: APIs live under src/api/, config under config/, and auto-generated types under types/generated/.
Step 2: Create the Tutor Content-Type
With the dev server running, open the Content-Type Builder in the Admin Panel and create a Collection Type named Tutor with these fields: name (Text), slug (UID attached to name), subject (Text), hourlyRate (Number, integer), bio (Rich text), and photo (Media, single). Strapi writes the schema and scaffolds the routes, controllers, and services under src/api/tutor/ for you. For a deeper look at how to approach content modeling, that guide covers the principles.
Verify the generated schema matches this (the photo attribute will appear with the settings you picked in the UI):
// src/api/tutor/content-types/tutor/schema.json
{
"kind": "collectionType",
"collectionName": "tutors",
"info": {
"singularName": "tutor",
"pluralName": "tutors",
"displayName": "Tutor",
"description": "Coaches and tutors offering sessions"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"name": {
"type": "string",
"required": true,
"minLength": 2,
"maxLength": 80
},
"slug": {
"type": "uid",
"targetField": "name"
},
"subject": {
"type": "string",
"required": true
},
"hourlyRate": {
"type": "integer",
"min": 0
},
"bio": {
"type": "richtext"
},
"photo": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": ["images"]
}
}
}The uid type auto-generates URL-safe slugs from name, which the frontend will use for tutor detail routes. Avoid reserved attribute names like status, locale, or anything prefixed with strapi, and don't define system attributes such as id, documentId, or publishedAt; Strapi manages those.
Step 3: Create the Booking Content-Type and Relation
Create a second Collection Type named Booking with sessionDate (Date, datetime), note (Long text), and a Relation field: Booking has one Tutor, Tutor has many Bookings. Disable Draft and Publish for bookings; a booking is either real or it isn't. For more on how relations work in Strapi, that guide covers the details.
The generated schema should look like this:
// src/api/booking/content-types/booking/schema.json
{
"kind": "collectionType",
"collectionName": "bookings",
"info": {
"singularName": "booking",
"pluralName": "bookings",
"displayName": "Booking",
"description": "Scheduled sessions between students and tutors"
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"sessionDate": {
"type": "datetime",
"required": true
},
"note": {
"type": "text",
"maxLength": 500
},
"tutor": {
"type": "relation",
"relation": "manyToOne",
"target": "api::tutor.tutor",
"inversedBy": "bookings"
},
"student": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user",
"inversedBy": "bookings"
}
}
}Strapi also adds the inverse side to the Tutor schema, per the model relations conventions: a bookings attribute with "relation": "oneToMany", "target": "api::booking.booking", and "mappedBy": "tutor". The manyToOne side owns the relation (inversedBy); the oneToMany side mirrors it (mappedBy).
The manyToOne side owns the relation because the foreign key lives on the booking's table: each booking row stores exactly one tutor reference, which is the natural place to hold the key. The Tutor side reads back the rows that point at it. Disabling Draft and Publish on bookings changes how they behave in the API. Bookings have no draft/published split, so every booking created through the API is immediately live and queryable, with no publish step in between. Tutors work the other way. Because the Tutor type keeps Draft and Publish on, status=published is the default filter for REST requests, and an unpublished tutor stays invisible to the frontend until you publish it.
Step 4: Block Double-Bookings with the Document Service API
This is where Strapi 5's biggest backend change shows up. The Document Service API replaces the deprecated Entity Service API from v4: you call strapi.documents(uid) and identify content by documentId, a stable alphanumeric string, rather than a numeric id. Core controllers and services in v5 already use it under the hood. For background on how Document Service middleware replaced lifecycle hooks, that guide explains the tradeoffs.
Extend the auto-generated booking controller so a tutor can't be booked twice for the same slot:
// src/api/booking/controllers/booking.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController(
'api::booking.booking',
({ strapi }) => ({
async create(ctx) {
const { data } = ctx.request.body;
const tutorDocumentId = data?.tutor?.connect?.[0];
const sessionDate = data?.sessionDate;
if (tutorDocumentId && sessionDate) {
const clashes = await strapi
.documents('api::booking.booking')
.count({
filters: {
tutor: { documentId: tutorDocumentId },
sessionDate: sessionDate,
},
});
if (clashes > 0) {
return ctx.badRequest(
'This tutor already has a booking at that time.'
);
}
}
return super.create(ctx);
},
})
);The count() method accepts the same filter operators as the rest of the Document Service API ($eq, $contains, $between, and about twenty more, plus $and/$or/$not). One thing worth knowing before you build on this API elsewhere: it returns unsanitized data, and by default it returns all fields but populates none, so relations need an explicit populate parameter.
That unsanitized-data behavior has a security edge you need to respect. If a custom controller calls strapi.documents(...).create() or .findMany() and returns the result straight to the client, it can leak private fields, password hashes on related users, or anything else marked private in a schema. This example sidesteps that by calling super.create() at the end rather than returning the output of a raw documents().create(). The core create() still runs Strapi's sanitization layer on the way out, so the response the student receives is filtered the same way the default endpoint would filter it. The custom code only adds the count() guard in front. Strapi 5's core controllers and services already use the Document Service internally, so you're extending that behavior, not replacing it. When you do need to return Document Service output directly, sanitize it yourself before it leaves the controller.
Step 5: Configure Permissions and JWT
Two roles need permissions. In the Admin Panel, go to Settings > Users and Permissions plugin > Roles:
- Public: under Tutor, allow
findandfindOne. Anyone can browse tutors. - Authenticated: under Booking, allow
create. Only logged-in students can book.
Each endpoint checks the Authorization header against these role permissions on every request, and users who register through /api/auth/local/register get the authenticated role automatically. For more on how API authorization works, that guide covers the mechanics.
Next, configure JWT behavior. Strapi 5 supports two modes: legacy-support (a single long-lived JWT) and refresh (short-lived access tokens with refresh tokens and session lifespans). The simpler mode is fine here:
// config/plugins.ts
export default () => ({
'users-permissions': {
config: {
jwtManagement: 'legacy-support',
jwt: {
expiresIn: '7d',
},
},
},
});If you later need server-to-server access (a cron job, a build pipeline), create an API token under Settings > Global settings > API Tokens instead of a user account. API tokens and admin tokens are strictly separated in Strapi 5: a Content API token is rejected on admin routes and vice versa. For broader guidance on API security, that checklist covers additional hardening steps.
Step 6: Seed a Few Tutors
In the Content Manager, create three or four tutors. Give each a name, subject, hourly rate, and bio, and upload a photo through the Media Library field (set the Alternative text while you're there; the frontend uses it). Strapi auto-generates responsive image formats at three default breakpoints: small (500px), medium (750px), and large (1000px); it also generates a separate thumbnail format. Publish each tutor. Drafts stay invisible to the API because status=published is the default for REST requests.
Building the Next.js Frontend
Step 1: Scaffold Next.js and Configure Images
Create the frontend in a sibling directory. Accept the TypeScript and App Router defaults when prompted; create-next-app installs the latest stable Next.js; this tutorial uses 16.2.11 from the Active LTS line at the time of writing.
npx create-next-app@latest tutor-marketplace-frontend
cd tutor-marketplace-frontend
npm install server-onlyAdd the Strapi URL as a server-only environment variable. It has no NEXT_PUBLIC_ prefix because all fetching happens in Server Components and Server Actions, so the URL never ships to the browser:
# .env.local
STRAPI_URL=http://localhost:1337Strapi serves uploads from its own host, and next/image refuses unconfigured remote hosts. Note that images.domains is deprecated in Next.js 16; use remotePatterns instead:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '1337',
pathname: '/uploads/**',
},
],
},
};
export default nextConfig;Step 2: Type the Strapi Response
Strapi 5's REST responses are flattened: attributes sit directly on each data object, with no data.attributes nesting, and every entry carries a string documentId. Model that shape once:
// lib/types.ts
export interface StrapiMedia {
documentId: string;
url: string;
alternativeText: string | null;
width: number;
height: number;
}
export interface Tutor {
documentId: string;
name: string;
slug: string;
subject: string;
hourlyRate: number;
bio: string;
photo: StrapiMedia | null;
}
export interface StrapiListResponse<T> {
data: T[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}Step 3: Build a Fetch Helper
A thin wrapper keeps the base URL, revalidation default, and error handling in one place. The server-only import makes the build fail loudly if someone imports this into a Client Component:
// lib/strapi.ts
import 'server-only';
const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
export async function strapiFetch<T>(
path: string,
options: RequestInit & {
next?: { revalidate?: number; tags?: string[] };
} = {}
): Promise<T> {
const res = await fetch(`${STRAPI_URL}${path}`, {
next: { revalidate: 60 },
...options,
});
if (!res.ok) {
throw new Error(
`Strapi request failed: ${res.status} ${res.statusText}`
);
}
return res.json() as Promise<T>;
}
export function mediaUrl(path: string | null | undefined): string | null {
if (!path) return null;
return path.startsWith('http') ? path : `${STRAPI_URL}${path}`;
}One Next.js 16 detail matters here: fetch requests are not cached by default. The next: { revalidate: 60 } option opts each request into a 60-second cache, which suits a tutor directory that changes a few times a day. Don't combine it with cache: 'no-store'; conflicting options like { revalidate: 3600, cache: 'no-store' } are not allowed.
Next.js 16 gives you two ways to cache this data, and it's worth knowing which one you're using. The fetch-level model, used here, sets time-based revalidation with { next: { revalidate: 60 } } directly on the request. The newer Cache Components model works differently: you mark an async function with the 'use cache' directive and tag it with cacheTag, then invalidate by tag. This tutorial sticks with fetch-level revalidation because a directory that changes a few times a day maps cleanly onto a timer, and the setup is one option on one fetch. Once you wire up a Strapi publish webhook, you can drop the timer in favor of on-demand revalidation with revalidateTag('tutors', 'max'), covered in Next steps. That path refetches only when content actually changes rather than on a fixed clock.
Step 4: List Tutors on the Homepage
Strapi returns no relations or media by default, so the query asks for the photo explicitly with populate=photo. Sorting and pagination ride along as query parameters. For details on how populate and filtering work, that guide covers the patterns:
// app/page.tsx
import Image from 'next/image';
import Link from 'next/link';
import { strapiFetch, mediaUrl } from '@/lib/strapi';
import type { StrapiListResponse, Tutor } from '@/lib/types';
export default async function HomePage() {
const { data: tutors } = await strapiFetch<StrapiListResponse<Tutor>>(
'/api/tutors?populate=photo&sort=name:asc&pagination[pageSize]=25'
);
return (
<main>
<h1>Find a coach or tutor</h1>
<ul>
{tutors.map((tutor) => {
const photo = tutor.photo;
const src = mediaUrl(photo?.url);
return (
<li key={tutor.documentId}>
{src && (
<Image
src={src}
alt={photo?.alternativeText ?? tutor.name}
width={120}
height={120}
/>
)}
<Link href={`/tutors/${tutor.slug}`}>{tutor.name}</Link>
<p>
{tutor.subject} · ${tutor.hourlyRate}/hour
</p>
</li>
);
})}
</ul>
</main>
);
}pagination[pageSize] defaults to 25 with a maximum of 100, and the meta.pagination block in the response gives you pageCount and total when you're ready to add paging.
Step 5: The Tutor Detail Page with Async Params
Next.js 16 fully removed synchronous access to params; the prop is a Promise you must await. The page looks up the tutor by slug using Strapi's filter syntax and calls notFound() for unknown slugs, which renders the 404 route and stops rendering the segment:
// app/tutors/[slug]/page.tsx
import { notFound } from 'next/navigation';
import Image from 'next/image';
import { strapiFetch, mediaUrl } from '@/lib/strapi';
import { createBooking } from '@/app/actions';
import type { StrapiListResponse, Tutor } from '@/lib/types';
export default async function TutorPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { data } = await strapiFetch<StrapiListResponse<Tutor>>(
`/api/tutors?filters[slug][$eq]=${encodeURIComponent(slug)}&populate=photo`
);
const tutor = data[0];
if (!tutor) notFound();
const photo = tutor.photo;
const src = mediaUrl(photo?.url);
return (
<main>
{src && (
<Image
src={src}
alt={photo?.alternativeText ?? tutor.name}
width={300}
height={300}
/>
)}
<h1>{tutor.name}</h1>
<p>
{tutor.subject} · ${tutor.hourlyRate}/hour
</p>
<div dangerouslySetInnerHTML={{ __html: tutor.bio }} />
<h2>Book a session</h2>
<form action={createBooking}>
<input
type="hidden"
name="tutorDocumentId"
value={tutor.documentId}
/>
<label>
Date and time
<input type="datetime-local" name="sessionDate" required />
</label>
<label>
Note for your tutor
<textarea name="note" maxLength={500} />
</label>
<button type="submit">Book session</button>
</form>
</main>
);
}The hidden input carries the tutor's documentId into the Server Action; that string is what the Strapi relation connect expects.
Step 6: Login and Booking with Server Actions
Authentication follows the standard Strapi flow: POST /api/auth/local with an identifier and password returns jwt and user. The login action stores the JWT in an httpOnly cookie, and the booking action reads it back and attaches the Authorization: Bearer header. Both cookies() calls are awaited, another Next.js 16 requirement. For a deeper walkthrough of JWT authentication with Strapi and Next.js, that guide covers the full flow.
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
export async function login(formData: FormData) {
const res = await fetch(`${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) {
redirect('/login?error=1');
}
const { jwt } = await res.json();
const cookieStore = await cookies();
cookieStore.set('jwt', jwt, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
redirect('/');
}
export async function createBooking(formData: FormData) {
const cookieStore = await cookies();
const jwt = cookieStore.get('jwt')?.value;
if (!jwt) redirect('/login');
const res = await fetch(`${STRAPI_URL}/api/bookings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify({
data: {
sessionDate: new Date(
String(formData.get('sessionDate'))
).toISOString(),
note: formData.get('note'),
tutor: {
connect: [String(formData.get('tutorDocumentId'))],
},
},
}),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error?.message ?? 'Booking failed');
}
redirect('/?booked=1');
}The login page is a plain server-rendered form wired to the action:
// app/login/page.tsx
import { login } from '@/app/actions';
export default function LoginPage() {
return (
<main>
<h1>Log in</h1>
<form action={login}>
<label>
Email
<input type="email" name="identifier" required />
</label>
<label>
Password
<input type="password" name="password" required />
</label>
<button type="submit">Log in</button>
</form>
</main>
);
}Putting It All Together
Start both servers in separate terminals:
# Terminal 1
cd tutor-marketplace-backend && npm run develop
# Terminal 2
cd tutor-marketplace-frontend && npm run devConfirm the API is serving flattened v5 responses:
curl "http://localhost:1337/api/tutors?populate=photo"You should see a data array where each tutor has documentId, name, and slug at the top level (no attributes wrapper), plus a meta.pagination block. Then register a test student:
curl -X POST http://localhost:1337/api/auth/local/register \
-H "Content-Type: application/json" \
-d '{"username":"amara","email":"amara@example.com","password":"Password123"}'The register response comes back with a jwt string and a user object. That user carries the authenticated role automatically, which is the role with create allowed for bookings, so the same token is enough to book without any extra role assignment. A successful booking POST returns the created booking as flattened JSON, with its own documentId, the sessionDate you submitted, and the linked tutor once you populate it. Submit the same tutor and slot a second time and the controller's count() guard fires before the write. Strapi responds with a 400 and an error body shaped like { error: { message: 'This tutor already has a booking at that time.' } }. That message is the exact string your ctx.badRequest() call passed, which is what the Server Action reads out of body?.error?.message when it throws.
Open http://localhost:3000. The homepage shows your published tutors with photos, subjects, and hourly rates. Click through to a profile, log in at /login with the test credentials, pick a date, and submit the booking form. The redirect back to the homepage with ?booked=1 confirms success, and the new entry appears in the Strapi Content Manager under Booking, linked to the right tutor. Submit the same tutor and time slot again and the request comes back as a 400 with "This tutor already has a booking at that time.", proof the Document Service count() guard is doing its job.
Next Steps
- Deploy the backend. Strapi Cloud provisions the database, SSL, and CDN from a connected Git repo. For self-hosting, plan on 2+ CPU cores and 4 GB+ memory, and move off SQLite to PostgreSQL 14.0–17.0 or MySQL 8.0–8.4 by setting
DATABASE_CLIENTandDATABASE_URL. The deployment guide covers hosting options. - Deploy the frontend. Vercel auto-detects Next.js projects from a Git repo; set
STRAPI_URLin its environment variables. Or run it yourself with the self-hosting guide. - Add on-demand revalidation. Tag the tutor fetch with
next: { tags: ['tutors'] }, then have a Strapi webhook hit a route that callsrevalidateTag('tutors', 'max')whenever content is published, replacing the 60-second timer. - Try the typed client.
@strapi/clientv1.6.2 wraps the REST API withcollection(),single(), andfiles()managers and takes an API token for auth. - Go multilingual. Internationalization ships in Strapi 5 core (no plugin), disabled by default and configured per Content-Type. The GraphQL plugin is another option if your frontend prefers a typed query layer.





