Appointment booking sits at the intersection of content management and transactional logic. Providers need profiles. Services need descriptions and durations. Schedules need structure. And the appointments themselves need booking status tracking, date filtering, and role-based access. Strapi 5 handles all of this through its content-type system, Document Service API, and built-in Users & Permissions plugin.
This tutorial walks you through building a booking platform that works for medical practices, dental offices, and salons. You'll define content-types for providers, services, and appointments, wire up custom controllers and services for availability checking, configure role-based permissions, and build React components that consume the Strapi 5 REST API.
In brief:
| Requirement | Version |
|---|---|
| Node.js | 24.16.0 (Active LTS, codename "Krypton") |
| npm | 11.x (bundled with Node 24) |
| Strapi | 5.47.0 |
| React | 19.1.0 (used standalone for the frontend) |
You should be comfortable with JavaScript, REST APIs, and basic React patterns. No database setup is needed: we'll use SQLite, which Strapi uses by default for local quickstart development.
Verify your Node.js version before starting:
1node -v
2# Expected: v24.xRun the Strapi command-line interface (CLI) to scaffold a new project:
1npx create-strapi@latest booking-platform --non-interactiveThe --non-interactive flag skips all prompts and defaults to TypeScript with SQLite. If you prefer JavaScript, run the command without --non-interactive and decline TypeScript when prompted. Once installation finishes, start the development server:
1cd booking-platform
2npm run developOpen http://localhost:1337/admin in your browser. Create your first admin account. Keep the terminal running.
Before building any frontend, configure CORS so your React app (running on port 5173) can reach the API. Open config/middlewares.js and replace the default strapi::cors entry:
1// config/middlewares.js
2module.exports = [
3 'strapi::logger',
4 'strapi::errors',
5 'strapi::security',
6 {
7 name: 'strapi::cors',
8 config: {
9 origin: ['http://localhost:5173', 'https://your-frontend.com'],
10 methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
11 headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
12 keepHeaderOnError: true,
13 },
14 },
15 'strapi::poweredBy',
16 'strapi::query',
17 'strapi::body',
18 'strapi::session',
19 'strapi::favicon',
20 'strapi::public',
21];Authorization is included by default in the CORS headers array in Strapi 5. You only need to explicitly list it if you override the default headers configuration.
Each service (consultation, root canal, haircut) has a name, duration, and price. Create the schema file manually:
1{
2 "kind": "collectionType",
3 "collectionName": "services",
4 "info": {
5 "singularName": "service",
6 "pluralName": "services",
7 "displayName": "Service"
8 },
9 "options": {
10 "draftAndPublish": true
11 },
12 "attributes": {
13 "name": {
14 "type": "string",
15 "required": true
16 },
17 "description": {
18 "type": "text"
19 },
20 "durationMinutes": {
21 "type": "integer",
22 "required": true
23 },
24 "price": {
25 "type": "decimal"
26 },
27 "industryType": {
28 "type": "enumeration",
29 "enum": ["medical", "dental", "salon"],
30 "default": "medical"
31 },
32 "isActive": {
33 "type": "boolean",
34 "default": true
35 },
36 "appointments": {
37 "type": "relation",
38 "relation": "oneToMany",
39 "target": "api::appointment.appointment",
40 "mappedBy": "service"
41 }
42 }
43}Draft and Publish is enabled here because services benefit from editorial review before going live to the booking interface.
Providers are the doctors, dentists, and stylists. They need a working hours component, so create the component first.
Create the working hours component:
1{
2 "collectionName": "components_shared_working_hours",
3 "info": {
4 "displayName": "Working Hours",
5 "icon": "clock"
6 },
7 "attributes": {
8 "dayOfWeek": {
9 "type": "string",
10 "required": true
11 },
12 "openTime": {
13 "type": "string"
14 },
15 "closeTime": {
16 "type": "string"
17 },
18 "isClosed": {
19 "type": "boolean"
20 }
21 }
22}Components live under src/components/<category>/ and are referenced by their UID (shared.working-hours). They can be created through the admin UI or manually.
Create the provider schema:
1{
2 "kind": "collectionType",
3 "collectionName": "providers",
4 "info": {
5 "singularName": "provider",
6 "pluralName": "providers",
7 "displayName": "Provider"
8 },
9 "options": {
10 "draftAndPublish": true
11 },
12 "attributes": {
13 "name": {
14 "type": "string",
15 "required": true
16 },
17 "email": {
18 "type": "email"
19 },
20 "specialization": {
21 "type": "enumeration",
22 "enum": ["general", "specialist", "consultant"]
23 },
24 "isActive": {
25 "type": "boolean"
26 },
27 "appointments": {
28 "type": "relation",
29 "relation": "oneToMany",
30 "target": "api::appointment.appointment",
31 "mappedBy": "provider"
32 },
33 "workingHours": {
34 "type": "component",
35 "repeatable": true,
36 "component": "shared.working-hours"
37 }
38 }
39}The workingHours field is a repeatable component: one entry per day of the week, stored as component records linked to the provider.
Create boilerplate API files for Service and Provider:
Strapi automatically creates default CRUD API endpoints when a content type is created. To customize the route, controller, and service files, create the following six files:
1// src/api/service/routes/service.js
2const { createCoreRouter } = require('@strapi/strapi').factories;
3module.exports = createCoreRouter('api::service.service');1// src/api/service/controllers/service.js
2const { createCoreController } = require('@strapi/strapi').factories;
3module.exports = createCoreController('api::service.service');1// src/api/service/services/service.js
2const { createCoreService } = require('@strapi/strapi').factories;
3module.exports = createCoreService('api::service.service');1// src/api/provider/routes/provider.js
2const { createCoreRouter } = require('@strapi/strapi').factories;
3module.exports = createCoreRouter('api::provider.provider');1// src/api/provider/controllers/provider.js
2const { createCoreController } = require('@strapi/strapi').factories;
3module.exports = createCoreController('api::provider.provider');1// src/api/provider/services/provider.js
2const { createCoreService } = require('@strapi/strapi').factories;
3module.exports = createCoreService('api::provider.provider');The appointment is the core transactional entity. It references both a provider and a service, carries booking state through an enumeration, and has a private field for internal notes that won't appear in API responses.
1{
2 "kind": "collectionType",
3 "collectionName": "appointments",
4 "info": {
5 "singularName": "appointment",
6 "pluralName": "appointments",
7 "displayName": "Appointment"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "clientName": {
14 "type": "string",
15 "required": true
16 },
17 "clientEmail": {
18 "type": "email",
19 "required": true
20 },
21 "appointmentDate": {
22 "type": "date",
23 "required": true
24 },
25 "startTime": {
26 "type": "time",
27 "required": true
28 },
29 "bookingStatus": {
30 "type": "enumeration",
31 "enum": ["pending", "confirmed", "cancelled", "completed", "no_show"],
32 "default": "pending",
33 "required": true
34 },
35 "notes": {
36 "type": "text"
37 },
38 "internalNotes": {
39 "type": "text",
40 "private": true
41 },
42 "provider": {
43 "type": "relation",
44 "relation": "manyToOne",
45 "target": "api::provider.provider",
46 "inversedBy": "appointments"
47 },
48 "service": {
49 "type": "relation",
50 "relation": "manyToOne",
51 "target": "api::service.service",
52 "inversedBy": "appointments"
53 }
54 }
55}draftAndPublish is false for appointments. These are transactional records, not editorial content. They should be queryable the moment they're created.
After creating all three schema files and the component file, restart Strapi (Ctrl+C, then npm run develop). The Content-Type Builder in the admin panel should show Provider, Service, and Appointment.
The default CRUD endpoints are useful, but a booking platform needs an availability check. Add a custom service method and a controller action to expose it.
Custom service:
1// src/api/appointment/services/appointment.js
2const { createCoreService } = require('@strapi/strapi').factories;
3
4module.exports = createCoreService('api::appointment.appointment', ({ strapi }) => ({
5
6 async getAvailableSlots(date, providerDocumentId) {
7 const existing = await strapi.documents('api::appointment.appointment').findMany({
8 filters: {
9 appointmentDate: { $eq: date },
10 provider: providerDocumentId,
11 bookingStatus: { $in: ['pending', 'confirmed'] },
12 },
13 });
14
15 const bookedTimes = existing.map((appt) => appt.startTime);
16
17 const allSlots = [
18 '09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
19 '13:00', '13:30', '14:00', '14:30', '15:00', '15:30',
20 '16:00', '16:30',
21 ];
22
23 const available = allSlots.filter((slot) => !bookedTimes.includes(slot));
24
25 return { date, providerDocumentId, available, bookedCount: existing.length };
26 },
27
28}));This uses the Document Service API (strapi.documents()), which is the v5 replacement for v4's Entity Service. Documents are identified by documentId (a string), not the numeric id from v4.
Custom controller:
1// src/api/appointment/controllers/appointment.js
2const { createCoreController } = require('@strapi/strapi').factories;
3
4module.exports = createCoreController('api::appointment.appointment', ({ strapi }) => ({
5
6 async checkAvailability(ctx) {
7 const { date, provider } = ctx.query;
8
9 if (!date || !provider) {
10 return ctx.badRequest('Both "date" and "provider" query parameters are required.');
11 }
12
13 const slots = await strapi
14 .service('api::appointment.appointment')
15 .getAvailableSlots(date, provider);
16
17 ctx.body = slots;
18 },
19
20}));Controllers use ctx.badRequest() for error responses. Services use error classes from @strapi/utils. This distinction matters when handling errors consistently in Strapi.
Custom route:
1// src/api/appointment/routes/01-custom-appointment.js
2module.exports = {
3 routes: [
4 {
5 method: 'GET',
6 path: '/appointments/availability',
7 handler: 'api::appointment.appointment.checkAvailability',
8 config: {
9 auth: false,
10 },
11 },
12 ],
13};The filename starts with 01- because route files load alphabetically. Missing this prefix is a common source of 404 errors when custom and core routes collide. Prefixing ensures this custom route registers before the auto-generated core routes.
Setting auth: false makes this endpoint publicly accessible, which makes sense for an availability check.
Core route file (used to configure or customize the default CRUD endpoints):
1// src/api/appointment/routes/appointment.js
2const { createCoreRouter } = require('@strapi/strapi').factories;
3
4module.exports = createCoreRouter('api::appointment.appointment');Restart Strapi after adding these files.
Prevent appointments from being created on past dates using a lifecycle hook:
1// src/api/appointment/content-types/appointment/lifecycles.js
2const { errors } = require('@strapi/utils');
3const { ApplicationError } = errors;
4
5module.exports = {
6 beforeCreate(event) {
7 const { data } = event.params;
8
9 const appointmentDate = new Date(data.appointmentDate);
10 const today = new Date();
11 today.setHours(0, 0, 0, 0);
12
13 if (appointmentDate < today) {
14 throw new ApplicationError('Cannot book appointments in the past.');
15 }
16
17 if (!data.bookingStatus) {
18 event.params.data.bookingStatus = 'pending';
19 }
20 },
21};Using ApplicationError from @strapi/utils (rather than a plain Error) ensures the error message surfaces correctly in the admin panel.
In the Strapi admin panel, navigate to Settings > Users & Permissions plugin > Roles.
For the Public role, enable:
find, findOne find, findOne For the Authenticated role, enable:
find, findOne find, findOne create, findOneSave both roles.
Verify that the public role works by requesting providers without an auth token:
1curl http://localhost:1337/api/providersIf the response returns a 403 Forbidden error, it is often because the collection is restricted by default and the appropriate Public or Authenticated role permissions for that endpoint have not been granted. Return to the Roles settings and confirm the checkboxes are checked.
Now unauthenticated visitors can browse providers and services, while only logged-in users can book appointments.
You can create test data through the Strapi admin panel or via the REST API. To create a provider with the API, use your admin JSON Web Token (JWT):
1curl -X POST http://localhost:1337/api/providers \
2 -H "Content-Type: application/json" \
3 -H "Authorization: Bearer <ADMIN_JWT>" \
4 -d '{
5 "data": {
6 "name": "Dr. Amara Osei",
7 "specialization": "general",
8 "isActive": true,
9 "workingHours": [
10 { "dayOfWeek": "Monday", "openTime": "09:00", "closeTime": "17:00", "isClosed": false },
11 { "dayOfWeek": "Saturday", "openTime": "09:00", "closeTime": "13:00", "isClosed": false },
12 { "dayOfWeek": "Sunday", "isClosed": true }
13 ]
14 }
15 }'After creating providers and services via the API or admin panel, publish them if Draft & Publish is enabled. Draft entries are not returned by default in API responses unless you add status=draft to the query.
Create two providers and two services, then confirm they appear at http://localhost:1337/api/providers?populate=workingHours and http://localhost:1337/api/services.
The v5 REST response format is flat: fields sit directly on each object in data, with no .attributes nesting. A provider response looks like this:
1{
2 "data": [
3 {
4 "id": 1,
5 "documentId": "abc123def456",
6 "name": "Dr. Amara Osei",
7 "specialization": "general",
8 "isActive": true
9 }
10 ],
11 "meta": {
12 "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 }
13 }
14}The frontend is a standalone React app that consumes the Strapi 5 REST API. You can use Create React App, Vite, or any other React setup. The components below work with any React 19+ environment.
From outside the booking-platform directory:
1npm create vite@latest booking-frontend -- --template react
2cd booking-frontend
3npm installCreate a shared API helper:
1// src/api.js
2const STRAPI_URL = 'http://localhost:1337';
3
4export function getStrapiURL(path) {
5 return `${STRAPI_URL}/api${path}`;
6}
7
8export function getToken() {
9 return localStorage.getItem('token');
10}
11
12export async function fetchAPI(path, options = {}) {
13 const url = getStrapiURL(path);
14 const token = getToken();
15
16 const headers = { 'Content-Type': 'application/json' };
17 if (token) {
18 headers.Authorization = `Bearer ${token}`;
19 }
20
21 const res = await fetch(url, { ...options, headers: { ...headers, ...options.headers } });
22 const data = await res.json();
23
24 if (!res.ok) {
25 throw new Error(data.error?.message || 'API request failed');
26 }
27
28 return data;
29}1// src/components/ProviderList.jsx
2import React, { useEffect, useState } from 'react';
3import { fetchAPI } from '../api';
4
5export default function ProviderList({ onSelectProvider }) {
6 const [providers, setProviders] = useState([]);
7 const [loading, setLoading] = useState(true);
8
9 useEffect(() => {
10 async function load() {
11 try {
12 const json = await fetchAPI(
13 '/providers?populate=workingHours&filters[isActive][$eq]=true&status=published'
14 );
15 setProviders(json.data);
16 } catch (err) {
17 console.error('Failed to load providers:', err.message);
18 } finally {
19 setLoading(false);
20 }
21 }
22 load();
23 }, []);
24
25 if (loading) return <p>Loading providers...</p>;
26
27 return (
28 <div>
29 <h2>Choose a Provider</h2>
30 <ul>
31 {providers.map((provider) => (
32 <li key={provider.documentId}>
33 <button onClick={() => onSelectProvider(provider)}>
34 {provider.name} ({provider.specialization})
35 </button>
36 </li>
37 ))}
38 </ul>
39 </div>
40 );
41}Fields are accessed directly on each item (provider.name, not provider.attributes.name). The documentId string is the stable identifier in Strapi 5.
Components like workingHours require explicit population via the populate query parameter. They are not included in responses by default.
1// src/components/LoginForm.jsx
2import React, { useState } from 'react';
3
4const STRAPI_URL = 'http://localhost:1337';
5
6export default function LoginForm({ onLoginSuccess }) {
7 const [identifier, setIdentifier] = useState('');
8 const [password, setPassword] = useState('');
9 const [error, setError] = useState(null);
10 const [isLoading, setIsLoading] = useState(false);
11
12 const handleSubmit = async (e) => {
13 e.preventDefault();
14 setIsLoading(true);
15 setError(null);
16
17 try {
18 const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
19 method: 'POST',
20 headers: { 'Content-Type': 'application/json' },
21 body: JSON.stringify({ identifier, password }),
22 });
23 const data = await res.json();
24
25 if (res.ok) {
26 localStorage.setItem('token', data.jwt);
27 onLoginSuccess(data.user);
28 } else {
29 setError(data.error?.message || 'Login failed');
30 }
31 } catch (err) {
32 setError('Network error. Is the Strapi server running?');
33 } finally {
34 setIsLoading(false);
35 }
36 };
37
38 return (
39 <form onSubmit={handleSubmit}>
40 <h2>Login to Book</h2>
41 <input
42 type="text"
43 placeholder="Email or username"
44 value={identifier}
45 onChange={(e) => setIdentifier(e.target.value)}
46 required
47 />
48 <input
49 type="password"
50 placeholder="Password"
51 value={password}
52 onChange={(e) => setPassword(e.target.value)}
53 required
54 />
55 {error && <p style={{ color: 'red' }}>{error}</p>}
56 <button type="submit" disabled={isLoading}>
57 {isLoading ? 'Logging in...' : 'Login'}
58 </button>
59 </form>
60 );
61}The /api/auth/local endpoint accepts either an email address or a username in the identifier field. The response includes a jwt token and a user object. All subsequent authenticated requests send this token via the Authorization: Bearer <jwt> header.
Register a test user first by sending a POST to /api/auth/local/register with username, email, and password fields. Users registered through this endpoint are automatically assigned the authenticated role.
1// src/components/BookingForm.jsx
2import React, { useEffect, useState } from 'react';
3import { fetchAPI } from '../api';
4
5export default function BookingForm({ provider }) {
6 const [services, setServices] = useState([]);
7 const [selectedService, setSelectedService] = useState('');
8 const [date, setDate] = useState('');
9 const [availableSlots, setAvailableSlots] = useState([]);
10 const [selectedSlot, setSelectedSlot] = useState('');
11 const [clientName, setClientName] = useState('');
12 const [clientEmail, setClientEmail] = useState('');
13 const [notes, setNotes] = useState('');
14 const [message, setMessage] = useState(null);
15
16 useEffect(() => {
17 async function loadServices() {
18 const json = await fetchAPI('/services?filters[isActive][$eq]=true&status=published');
19 setServices(json.data);
20 }
21 loadServices();
22 }, []);
23
24 useEffect(() => {
25 if (!date || !provider) return;
26 async function loadSlots() {
27 const json = await fetchAPI(
28 `/appointments/availability?date=${date}&provider=${provider.documentId}`
29 );
30 setAvailableSlots(json.available || []);
31 }
32 loadSlots();
33 }, [date, provider]);
34
35 const handleSubmit = async (e) => {
36 e.preventDefault();
37 setMessage(null);
38
39 try {
40 const result = await fetchAPI('/appointments', {
41 method: 'POST',
42 body: JSON.stringify({
43 data: {
44 clientName,
45 clientEmail,
46 appointmentDate: date,
47 startTime: selectedSlot,
48 notes,
49 provider: provider.documentId,
50 service: selectedService,
51 },
52 }),
53 });
54 setMessage(`Booked. Appointment ID: ${result.data.documentId}`);
55 } catch (err) {
56 setMessage(`Error: ${err.message}`);
57 }
58 };
59
60 return (
61 <form onSubmit={handleSubmit}>
62 <h2>Book with {provider.name}</h2>
63
64 <label>
65 Service:
66 <select value={selectedService} onChange={(e) => setSelectedService(e.target.value)} required>
67 <option value="">Select a service</option>
68 {services.map((svc) => (
69 <option key={svc.documentId} value={svc.documentId}>
70 {svc.name} ({svc.durationMinutes} min)
71 </option>
72 ))}
73 </select>
74 </label>
75
76 <label>
77 Date:
78 <input type="date" value={date} onChange={(e) => setDate(e.target.value)} required />
79 </label>
80
81 {availableSlots.length > 0 && (
82 <label>
83 Time:
84 <select value={selectedSlot} onChange={(e) => setSelectedSlot(e.target.value)} required>
85 <option value="">Select a time</option>
86 {availableSlots.map((slot) => (
87 <option key={slot} value={slot}>{slot}</option>
88 ))}
89 </select>
90 </label>
91 )}
92
93 <label>
94 Name:
95 <input type="text" value={clientName} onChange={(e) => setClientName(e.target.value)} required />
96 </label>
97
98 <label>
99 Email:
100 <input type="email" value={clientEmail} onChange={(e) => setClientEmail(e.target.value)} required />
101 </label>
102
103 <label>
104 Notes:
105 <textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
106 </label>
107
108 <button type="submit">Confirm Booking</button>
109 {message && <p>{message}</p>}
110 </form>
111 );
112}For singular (many-to-one) relations in Strapi 5 POST requests, pass the documentId string directly as the field value. The entire payload is wrapped in a data object per the REST API relations documentation.
1// src/App.jsx
2import React, { useState } from 'react';
3import LoginForm from './components/LoginForm';
4import ProviderList from './components/ProviderList';
5import BookingForm from './components/BookingForm';
6
7export default function App() {
8 const [user, setUser] = useState(null);
9 const [selectedProvider, setSelectedProvider] = useState(null);
10
11 if (!user) {
12 return <LoginForm onLoginSuccess={setUser} />;
13 }
14
15 if (!selectedProvider) {
16 return (
17 <div>
18 <p>Welcome, {user.username}</p>
19 <ProviderList onSelectProvider={setSelectedProvider} />
20 </div>
21 );
22 }
23
24 return (
25 <div>
26 <p>Welcome, {user.username}</p>
27 <button onClick={() => setSelectedProvider(null)}>Back to Providers</button>
28 <BookingForm provider={selectedProvider} />
29 </div>
30 );
31}Open two terminals. In the first, start Strapi:
1cd booking-platform
2npm run developIn the second, start the React frontend:
1cd booking-frontend
2npm run devTo verify the backend independently, test the availability endpoint with curl:
1curl "http://localhost:1337/api/appointments/availability?date=2025-07-15&provider=YOUR_PROVIDER_DOCUMENT_ID"The response should look like:
1{
2 "date": "2025-07-15",
3 "providerDocumentId": "abc123def456",
4 "available": ["09:00", "09:30", "10:00", "10:30", "11:00", "11:30", "13:00", "13:30", "14:00", "14:30", "15:00", "15:30", "16:00", "16:30"],
5 "bookedCount": 0
6}Open http://localhost:5173 (Vite's default port). Log in with the test user you registered earlier. Select a provider, pick a date, choose an available time slot, fill in the form, and submit. The appointment appears in the Strapi admin panel under Content Manager > Appointment with a bookingStatus of "pending."
afterCreate lifecycle hook on appointments. @strapi/client SDK. Replace raw fetch calls with the official Strapi client for a typed, ergonomic API layer. find and update permissions on appointments so providers can confirm or cancel bookings. npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.