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:
- Defining collection content-types with relations, enumerations, and components in Strapi 5
- Using the Document Service API (the v5 replacement for v4's Entity Service)
- Building custom routes, controllers, and services for booking logic
- Configuring Cross-Origin Resource Sharing (CORS), role-based permissions, and authentication for a React frontend client
Prerequisites
| 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:
node -v
# Expected: v24.xSetting Up the Strapi 5 Backend
Step 1: Create a New Strapi 5 Project
Run the Strapi command-line interface (CLI) to scaffold a new project:
npx 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:
cd booking-platform
npm run developOpen http://localhost:1337/admin in your browser. Create your first admin account. Keep the terminal running.
Step 2: Configure CORS for Frontend Access
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:
// config/middlewares.js
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
{
name: 'strapi::cors',
config: {
origin: ['http://localhost:5173', 'https://your-frontend.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
keepHeaderOnError: true,
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];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.
Step 3: Define the Service Content-Type
Each service (consultation, root canal, haircut) has a name, duration, and price. Create the schema file manually:
{
"kind": "collectionType",
"collectionName": "services",
"info": {
"singularName": "service",
"pluralName": "services",
"displayName": "Service"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"description": {
"type": "text"
},
"durationMinutes": {
"type": "integer",
"required": true
},
"price": {
"type": "decimal"
},
"industryType": {
"type": "enumeration",
"enum": ["medical", "dental", "salon"],
"default": "medical"
},
"isActive": {
"type": "boolean",
"default": true
},
"appointments": {
"type": "relation",
"relation": "oneToMany",
"target": "api::appointment.appointment",
"mappedBy": "service"
}
}
}Draft and Publish is enabled here because services benefit from editorial review before going live to the booking interface.
Step 4: Define the Provider Content-Type
Providers are the doctors, dentists, and stylists. They need a working hours component, so create the component first.
Create the working hours component:
{
"collectionName": "components_shared_working_hours",
"info": {
"displayName": "Working Hours",
"icon": "clock"
},
"attributes": {
"dayOfWeek": {
"type": "string",
"required": true
},
"openTime": {
"type": "string"
},
"closeTime": {
"type": "string"
},
"isClosed": {
"type": "boolean"
}
}
}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:
{
"kind": "collectionType",
"collectionName": "providers",
"info": {
"singularName": "provider",
"pluralName": "providers",
"displayName": "Provider"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"email": {
"type": "email"
},
"specialization": {
"type": "enumeration",
"enum": ["general", "specialist", "consultant"]
},
"isActive": {
"type": "boolean"
},
"appointments": {
"type": "relation",
"relation": "oneToMany",
"target": "api::appointment.appointment",
"mappedBy": "provider"
},
"workingHours": {
"type": "component",
"repeatable": true,
"component": "shared.working-hours"
}
}
}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:
// src/api/service/routes/service.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::service.service');// src/api/service/controllers/service.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::service.service');// src/api/service/services/service.js
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::service.service');// src/api/provider/routes/provider.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::provider.provider');// src/api/provider/controllers/provider.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::provider.provider');// src/api/provider/services/provider.js
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::provider.provider');Step 5: Define the Appointment Content-Type
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.
{
"kind": "collectionType",
"collectionName": "appointments",
"info": {
"singularName": "appointment",
"pluralName": "appointments",
"displayName": "Appointment"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"clientName": {
"type": "string",
"required": true
},
"clientEmail": {
"type": "email",
"required": true
},
"appointmentDate": {
"type": "date",
"required": true
},
"startTime": {
"type": "time",
"required": true
},
"bookingStatus": {
"type": "enumeration",
"enum": ["pending", "confirmed", "cancelled", "completed", "no_show"],
"default": "pending",
"required": true
},
"notes": {
"type": "text"
},
"internalNotes": {
"type": "text",
"private": true
},
"provider": {
"type": "relation",
"relation": "manyToOne",
"target": "api::provider.provider",
"inversedBy": "appointments"
},
"service": {
"type": "relation",
"relation": "manyToOne",
"target": "api::service.service",
"inversedBy": "appointments"
}
}
}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.
Step 6: Build Custom Service and Controller for Availability
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:
// src/api/appointment/services/appointment.js
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::appointment.appointment', ({ strapi }) => ({
async getAvailableSlots(date, providerDocumentId) {
const existing = await strapi.documents('api::appointment.appointment').findMany({
filters: {
appointmentDate: { $eq: date },
provider: providerDocumentId,
bookingStatus: { $in: ['pending', 'confirmed'] },
},
});
const bookedTimes = existing.map((appt) => appt.startTime);
const allSlots = [
'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',
];
const available = allSlots.filter((slot) => !bookedTimes.includes(slot));
return { date, providerDocumentId, available, bookedCount: existing.length };
},
}));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:
// src/api/appointment/controllers/appointment.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::appointment.appointment', ({ strapi }) => ({
async checkAvailability(ctx) {
const { date, provider } = ctx.query;
if (!date || !provider) {
return ctx.badRequest('Both "date" and "provider" query parameters are required.');
}
const slots = await strapi
.service('api::appointment.appointment')
.getAvailableSlots(date, provider);
ctx.body = slots;
},
}));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:
// src/api/appointment/routes/01-custom-appointment.js
module.exports = {
routes: [
{
method: 'GET',
path: '/appointments/availability',
handler: 'api::appointment.appointment.checkAvailability',
config: {
auth: false,
},
},
],
};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):
// src/api/appointment/routes/appointment.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::appointment.appointment');Restart Strapi after adding these files.
Step 7: Add Lifecycle Validation
Prevent appointments from being created on past dates using a lifecycle hook:
// src/api/appointment/content-types/appointment/lifecycles.js
const { errors } = require('@strapi/utils');
const { ApplicationError } = errors;
module.exports = {
beforeCreate(event) {
const { data } = event.params;
const appointmentDate = new Date(data.appointmentDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (appointmentDate < today) {
throw new ApplicationError('Cannot book appointments in the past.');
}
if (!data.bookingStatus) {
event.params.data.bookingStatus = 'pending';
}
},
};Using ApplicationError from @strapi/utils (rather than a plain Error) ensures the error message surfaces correctly in the admin panel.
Step 8: Configure Permissions
In the Strapi admin panel, navigate to Settings > Users & Permissions plugin > Roles.
For the Public role, enable:
- Provider:
find,findOne - Service:
find,findOne - Appointment: none (booking requires authentication)
For the Authenticated role, enable:
- Provider:
find,findOne - Service:
find,findOne - Appointment:
create,findOne
Save both roles.
Verify that the public role works by requesting providers without an auth token:
curl 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.
Step 9: Seed Test Data
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):
curl -X POST http://localhost:1337/api/providers \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <ADMIN_JWT>" \
-d '{
"data": {
"name": "Dr. Amara Osei",
"specialization": "general",
"isActive": true,
"workingHours": [
{ "dayOfWeek": "Monday", "openTime": "09:00", "closeTime": "17:00", "isClosed": false },
{ "dayOfWeek": "Saturday", "openTime": "09:00", "closeTime": "13:00", "isClosed": false },
{ "dayOfWeek": "Sunday", "isClosed": true }
]
}
}'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:
{
"data": [
{
"id": 1,
"documentId": "abc123def456",
"name": "Dr. Amara Osei",
"specialization": "general",
"isActive": true
}
],
"meta": {
"pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 }
}
}Building a React Frontend for Strapi 5
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.
Step 1: Set Up the Project
From outside the booking-platform directory:
npm create vite@latest booking-frontend -- --template react
cd booking-frontend
npm installCreate a shared API helper:
// src/api.js
const STRAPI_URL = 'http://localhost:1337';
export function getStrapiURL(path) {
return `${STRAPI_URL}/api${path}`;
}
export function getToken() {
return localStorage.getItem('token');
}
export async function fetchAPI(path, options = {}) {
const url = getStrapiURL(path);
const token = getToken();
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const res = await fetch(url, { ...options, headers: { ...headers, ...options.headers } });
const data = await res.json();
if (!res.ok) {
throw new Error(data.error?.message || 'API request failed');
}
return data;
}Step 2: Build the Provider List
// src/components/ProviderList.jsx
import React, { useEffect, useState } from 'react';
import { fetchAPI } from '../api';
export default function ProviderList({ onSelectProvider }) {
const [providers, setProviders] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
try {
const json = await fetchAPI(
'/providers?populate=workingHours&filters[isActive][$eq]=true&status=published'
);
setProviders(json.data);
} catch (err) {
console.error('Failed to load providers:', err.message);
} finally {
setLoading(false);
}
}
load();
}, []);
if (loading) return <p>Loading providers...</p>;
return (
<div>
<h2>Choose a Provider</h2>
<ul>
{providers.map((provider) => (
<li key={provider.documentId}>
<button onClick={() => onSelectProvider(provider)}>
{provider.name} ({provider.specialization})
</button>
</li>
))}
</ul>
</div>
);
}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.
Step 3: Build the Login Form
// src/components/LoginForm.jsx
import React, { useState } from 'react';
const STRAPI_URL = 'http://localhost:1337';
export default function LoginForm({ onLoginSuccess }) {
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
});
const data = await res.json();
if (res.ok) {
localStorage.setItem('token', data.jwt);
onLoginSuccess(data.user);
} else {
setError(data.error?.message || 'Login failed');
}
} catch (err) {
setError('Network error. Is the Strapi server running?');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Login to Book</h2>
<input
type="text"
placeholder="Email or username"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
);
}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.
Step 4: Build the Booking Form
// src/components/BookingForm.jsx
import React, { useEffect, useState } from 'react';
import { fetchAPI } from '../api';
export default function BookingForm({ provider }) {
const [services, setServices] = useState([]);
const [selectedService, setSelectedService] = useState('');
const [date, setDate] = useState('');
const [availableSlots, setAvailableSlots] = useState([]);
const [selectedSlot, setSelectedSlot] = useState('');
const [clientName, setClientName] = useState('');
const [clientEmail, setClientEmail] = useState('');
const [notes, setNotes] = useState('');
const [message, setMessage] = useState(null);
useEffect(() => {
async function loadServices() {
const json = await fetchAPI('/services?filters[isActive][$eq]=true&status=published');
setServices(json.data);
}
loadServices();
}, []);
useEffect(() => {
if (!date || !provider) return;
async function loadSlots() {
const json = await fetchAPI(
`/appointments/availability?date=${date}&provider=${provider.documentId}`
);
setAvailableSlots(json.available || []);
}
loadSlots();
}, [date, provider]);
const handleSubmit = async (e) => {
e.preventDefault();
setMessage(null);
try {
const result = await fetchAPI('/appointments', {
method: 'POST',
body: JSON.stringify({
data: {
clientName,
clientEmail,
appointmentDate: date,
startTime: selectedSlot,
notes,
provider: provider.documentId,
service: selectedService,
},
}),
});
setMessage(`Booked. Appointment ID: ${result.data.documentId}`);
} catch (err) {
setMessage(`Error: ${err.message}`);
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Book with {provider.name}</h2>
<label>
Service:
<select value={selectedService} onChange={(e) => setSelectedService(e.target.value)} required>
<option value="">Select a service</option>
{services.map((svc) => (
<option key={svc.documentId} value={svc.documentId}>
{svc.name} ({svc.durationMinutes} min)
</option>
))}
</select>
</label>
<label>
Date:
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} required />
</label>
{availableSlots.length > 0 && (
<label>
Time:
<select value={selectedSlot} onChange={(e) => setSelectedSlot(e.target.value)} required>
<option value="">Select a time</option>
{availableSlots.map((slot) => (
<option key={slot} value={slot}>{slot}</option>
))}
</select>
</label>
)}
<label>
Name:
<input type="text" value={clientName} onChange={(e) => setClientName(e.target.value)} required />
</label>
<label>
Email:
<input type="email" value={clientEmail} onChange={(e) => setClientEmail(e.target.value)} required />
</label>
<label>
Notes:
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</label>
<button type="submit">Confirm Booking</button>
{message && <p>{message}</p>}
</form>
);
}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.
Step 5: Wire Up the App
// src/App.jsx
import React, { useState } from 'react';
import LoginForm from './components/LoginForm';
import ProviderList from './components/ProviderList';
import BookingForm from './components/BookingForm';
export default function App() {
const [user, setUser] = useState(null);
const [selectedProvider, setSelectedProvider] = useState(null);
if (!user) {
return <LoginForm onLoginSuccess={setUser} />;
}
if (!selectedProvider) {
return (
<div>
<p>Welcome, {user.username}</p>
<ProviderList onSelectProvider={setSelectedProvider} />
</div>
);
}
return (
<div>
<p>Welcome, {user.username}</p>
<button onClick={() => setSelectedProvider(null)}>Back to Providers</button>
<BookingForm provider={selectedProvider} />
</div>
);
}Putting It All Together
Open two terminals. In the first, start Strapi:
cd booking-platform
npm run developIn the second, start the React frontend:
cd booking-frontend
npm run devTo verify the backend independently, test the availability endpoint with curl:
curl "http://localhost:1337/api/appointments/availability?date=2025-07-15&provider=YOUR_PROVIDER_DOCUMENT_ID"The response should look like:
{
"date": "2025-07-15",
"providerDocumentId": "abc123def456",
"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"],
"bookedCount": 0
}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."
Next Steps
- Deploy to production. Use the Strapi deployment guide with PostgreSQL instead of SQLite. Build and start your app using the production commands recommended by your Strapi setup and package scripts.
- Add email notifications. Install the Strapi Email plugin and trigger confirmation emails from the
afterCreatelifecycle hook on appointments. - Implement the
@strapi/clientSDK. Replace rawfetchcalls with the official Strapi client for a typed, ergonomic API layer. - Build a provider dashboard. Create a custom role for providers in the Users & Permissions settings, then add
findandupdatepermissions on appointments so providers can confirm or cancel bookings. - Explore integrations. Connect your booking platform to payment processors, calendar apps, or notification services through the Strapi integrations ecosystem.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.