Running webinars and workshops sounds simple until you hit the operational details: capacity limits that need enforcing, a waitlist that has to promote people fairly when spots open, confirmation emails that fire reliably, and event pages that actually look good when someone shares them on social media. Stitch those together with spreadsheets and a few third-party forms, and the whole thing falls apart the first time two people register for the last seat at the same time.
This tutorial walks through building a webinar and workshop registration platform with Strapi and Next.js 16 that handles all of it. Strapi 5 manages event content, enforces capacity through a custom controller, runs the waitlist promotion logic, and fires webhooks for email integration. Next.js 16 renders SEO-optimized event pages with server-side data fetching and handles registration through Server Actions. If you want to read more about the Next.js integration before diving in, the integration page covers the basics.
By the end, you'll have a working system where organizers draft events, attendees register through public pages, and the waitlist auto-promotes when someone cancels.
In brief:
- Strapi 5's custom controllers and services enforce event capacity and manage waitlist promotion using the Document Service API.
- Document Service middleware keeps a
registrationCountfield in sync without the double-fire problems of model lifecycle hooks. - Next.js 16 Server Components and the
generateMetadatafunction produce SEO-friendly event pages with Open Graph tags for social sharing. - Webhooks fire on registration and waitlist promotion so you can wire up confirmation emails through any email service.
What We're Building
The platform centers on three content types in Strapi: Events, Registrations, and Speakers. Organizers create webinar, workshop, or meetup events with a capacity limit, assign speakers, and publish when registration should open. Attendees browse a public listing, open an event detail page, and register through a form. When an event hits capacity, new registrations land on a waitlist with a position number instead of being rejected.
The interesting logic lives in the registration flow. A custom Strapi controller checks the current registration count against capacity before accepting anyone. If there's room, the attendee gets confirmed. If the event is full, they get waitlisted with a position. When a confirmed attendee cancels, a custom service finds the earliest waitlisted registrant and promotes them automatically, firing a webhook so you can send a "you're in" email.
On the frontend, Next.js 16 renders everything server-side for SEO. Event detail pages use the generateMetadata function to produce dynamic titles, descriptions, and Open Graph images, which matters when you're promoting webinars across social platforms.
What you'll learn:
- Defining Event, Registration, and Speaker content types with relations in Strapi 5
- Writing a capacity-aware registration controller and a waitlist promotion service
- Keeping derived counts in sync with Document Service middleware
- Configuring Draft & Publish and webhooks for event scheduling and email notifications
- Building public event pages, registration forms, and an attendee dashboard in Next.js 16
Prerequisites
Pin these versions to keep your code matching the tutorial:
- Node.js LTS v22 or v24. Strapi 5 supports Active and Maintenance LTS versions. Currently, v20, v22, and v24 are supported. Avoid odd-numbered releases like v23.
- Strapi 5.x (latest stable). Verify the exact version when you run
npx create-strapi@latest. - Next.js 16.2.x with the App Router, Server Components, and Server Actions.
- React 19.2.x, covered in the React 19 release, which ships with Next.js 16.
- Tailwind CSS v4.x for styling.
- date-fns format (latest stable) for date and time formatting.
- PostgreSQL production 16.x. The official Strapi Docker Compose example uses
postgres:16-alpine.
You'll need comfort with TypeScript, REST APIs, and React Server Components. A code editor like VS Code helps. Familiarity with the Strapi admin panel and basic content modeling will make the backend steps go faster. You can also browse the Strapi marketplace for plugins that extend the platform.
Setting Up the Strapi Backend
Step 1: Install Strapi 5
Create the project with the official CLI. Strapi 5 replaced the old --quickstart flag with an interactive setup flow. For a scripted install, pass the database flags directly (shown below) along with --skip-cloud to bypass the cloud login prompt.
npx create-strapi@latest webinar-platformThe CLI prompts you for a database, TypeScript, and a few other choices. For local development, SQLite is fine. When you're ready for production, point Strapi at PostgreSQL with the database flags:
npx create-strapi@latest webinar-platform \
--skip-cloud \
--dbclient postgres \
--dbhost 127.0.0.1 \
--dbport 5432 \
--dbname webinar \
--dbusername strapi \
--dbpassword strapi \
--dbssl=falseOnce installation finishes, start the development server:
cd webinar-platform
npm run developStrapi opens at http://localhost:1337/admin. Create your admin account, and you're ready to model content.
Step 2: Define the Event, Registration, and Speaker Content-Types
You can build these in the Content-Type Builder or write the schema files directly. The schema files live at ./src/api/[api-name]/content-types/[content-type-name]/schema.json. Here's the Event schema.
{
"kind": "collectionType",
"collectionName": "events",
"info": {
"singularName": "event",
"pluralName": "events",
"displayName": "Event"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true,
"minLength": 3,
"maxLength": 200
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true
},
"description": {
"type": "richtext"
},
"eventType": {
"type": "enumeration",
"enum": ["webinar", "workshop", "meetup"],
"default": "webinar",
"required": true
},
"startDate": {
"type": "datetime",
"required": true
},
"duration": {
"type": "integer"
},
"capacity": {
"type": "integer",
"required": true
},
"registrationCount": {
"type": "integer",
"default": 0
},
"isWaitlistEnabled": {
"type": "boolean",
"default": true
},
"isFeatured": {
"type": "boolean",
"default": false
},
"coverImage": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"speakers": {
"type": "relation",
"relation": "manyToMany",
"target": "api::speaker.speaker",
"inversedBy": "events"
},
"registrations": {
"type": "relation",
"relation": "oneToMany",
"target": "api::registration.registration",
"mappedBy": "event"
}
}
}The description field uses the Blocks editor (CLI type richtext), which stores content as a JSON array of block objects. The registrationCount integer holds the derived count you'll sync with middleware in a later step.
The Registration schema comes next. The event relation is the owning side and uses inversedBy to point back to the Event's registrations.
{
"kind": "collectionType",
"collectionName": "registrations",
"info": {
"singularName": "registration",
"pluralName": "registrations",
"displayName": "Registration"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"attendeeName": {
"type": "string",
"required": true
},
"attendeeEmail": {
"type": "email",
"required": true
},
"registrationStatus": {
"type": "enumeration",
"enum": ["confirmed", "waitlisted", "cancelled"],
"default": "confirmed",
"required": true
},
"waitlistPosition": {
"type": "integer"
},
"registeredAt": {
"type": "datetime"
},
"event": {
"type": "relation",
"relation": "manyToOne",
"target": "api::event.event",
"inversedBy": "registrations"
}
}
}Finally, the Speaker schema with a many-to-many relation back to events.
{
"kind": "collectionType",
"collectionName": "speakers",
"info": {
"singularName": "speaker",
"pluralName": "speakers",
"displayName": "Speaker"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"bio": {
"type": "text"
},
"title": {
"type": "string"
},
"company": {
"type": "string"
},
"headshot": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"events": {
"type": "relation",
"relation": "manyToMany",
"target": "api::event.event",
"mappedBy": "speakers"
}
}
}Restart Strapi after adding schema files so it picks up the new content types. With these in place, you have the data model for events, the people registering, and the speakers presenting.
Step 3: Build the Capacity-Aware Registration Controller
The default REST endpoint would happily create registrations past capacity. You need a custom action that checks the count first. Start with the service, where the actual capacity logic lives.
// src/api/registration/services/registration.js
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::registration.registration', ({ strapi }) => ({
async registerAttendee({ eventDocumentId, attendeeName, attendeeEmail }) {
const event = await strapi.documents('api::event.event').findOne({
documentId: eventDocumentId,
status: 'published',
fields: ['capacity', 'registrationCount', 'isWaitlistEnabled'],
});
if (!event) {
throw new Error('Event not found');
}
const hasRoom = event.registrationCount < event.capacity;
const registrationStatus = hasRoom ? 'confirmed' : 'waitlisted';
if (registrationStatus === 'waitlisted' && !event.isWaitlistEnabled) {
throw new Error('Event is at capacity and waitlist is disabled');
}
let waitlistPosition = null;
if (registrationStatus === 'waitlisted') {
const waitlistedCount = await strapi.documents('api::registration.registration').count({
filters: {
event: { documentId: eventDocumentId },
registrationStatus: { $eq: 'waitlisted' },
},
status: 'published',
});
waitlistPosition = waitlistedCount + 1;
}
const registration = await strapi.documents('api::registration.registration').create({
data: {
attendeeName,
attendeeEmail,
registrationStatus,
waitlistPosition,
event: { connect: [{ documentId: eventDocumentId }] },
},
status: 'published',
});
return { registration, status: registrationStatus, waitlistPosition };
},
}));The service compares registrationCount against capacity. If there's room, the attendee gets confirmed. If not, and the waitlist is enabled, they're waitlisted with a position equal to the current waitlist length plus one. Note the count() call uses status: 'published' so only published registrations get counted, which matters because in Strapi 5 counting with status: 'draft' returns the total document count regardless of publication. The relation is set using { connect: [{ documentId: eventDocumentId }] } syntax.
The controller exposes this as a custom route action.
// src/api/registration/controllers/registration.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::registration.registration', ({ strapi }) => ({
async register(ctx) {
const { eventDocumentId, attendeeName, attendeeEmail } = ctx.request.body;
if (!eventDocumentId || !attendeeName || !attendeeEmail) {
return ctx.badRequest('eventDocumentId, attendeeName, and attendeeEmail are required');
}
const result = await strapi
.service('api::registration.registration')
.registerAttendee({ eventDocumentId, attendeeName, attendeeEmail });
const sanitized = await this.sanitizeOutput(result.registration, ctx);
ctx.body = {
data: sanitized,
status: result.status,
waitlistPosition: result.waitlistPosition,
};
},
}));The Document Service returns unsanitized data, so the controller calls this.sanitizeOutput() before responding. Register the custom route so the action is reachable.
// src/api/registration/routes/01-custom-registration.js
module.exports = {
routes: [
{
method: 'POST',
path: '/registrations/register',
handler: 'registration.register',
config: {
auth: false,
},
},
],
};That auth: false makes the registration endpoint public so anyone can sign up. Tighten this with rate limiting before production. The file is named 01-custom-registration.js so it loads before core routes alphabetically, avoiding shadowing issues.
Step 4: Build the Waitlist Promotion Service
When a confirmed attendee cancels, the earliest waitlisted person should move up. Add a promoteFromWaitlist method to the registration service.
// src/api/registration/services/registration.js
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::registration.registration', ({ strapi }) => ({
async registerAttendee({ eventDocumentId, attendeeName, attendeeEmail }) {
const event = await strapi.documents('api::event.event').findOne({
documentId: eventDocumentId,
status: 'published',
fields: ['capacity', 'registrationCount', 'isWaitlistEnabled'],
});
if (!event) {
throw new Error('Event not found');
}
const hasRoom = event.registrationCount < event.capacity;
const registrationStatus = hasRoom ? 'confirmed' : 'waitlisted';
if (registrationStatus === 'waitlisted' && !event.isWaitlistEnabled) {
throw new Error('Event is at capacity and waitlist is disabled');
}
let waitlistPosition = null;
if (registrationStatus === 'waitlisted') {
const waitlistedCount = await strapi.documents('api::registration.registration').count({
filters: {
event: { documentId: eventDocumentId },
registrationStatus: { $eq: 'waitlisted' },
},
status: 'published',
});
waitlistPosition = waitlistedCount + 1;
}
const registration = await strapi.documents('api::registration.registration').create({
data: {
attendeeName,
attendeeEmail,
registrationStatus,
waitlistPosition,
event: { connect: [{ documentId: eventDocumentId }] },
},
status: 'published',
});
return { registration, status: registrationStatus, waitlistPosition };
},
async promoteFromWaitlist(eventDocumentId) {
const waitlisted = await strapi.documents('api::registration.registration').findMany({
filters: {
event: { documentId: eventDocumentId },
registrationStatus: { $eq: 'waitlisted' },
},
status: 'published',
sort: 'createdAt:asc',
limit: 1,
start: 0,
});
if (waitlisted.length === 0) {
return null;
}
const promoted = await strapi.documents('api::registration.registration').update({
documentId: waitlisted[0].documentId,
data: { registrationStatus: 'confirmed', waitlistPosition: null },
status: 'published',
});
const remaining = await strapi.documents('api::registration.registration').findMany({
filters: {
event: { documentId: eventDocumentId },
registrationStatus: { $eq: 'waitlisted' },
},
status: 'published',
sort: ['createdAt:asc'],
});
for (let i = 0; i < remaining.length; i++) {
await strapi.documents('api::registration.registration').update({
documentId: remaining[i].documentId,
data: { waitlistPosition: i + 1 },
status: 'published',
});
}
return promoted;
},
async cancelRegistration(documentId) {
const registration = await strapi.documents('api::registration.registration').findOne({
documentId,
status: 'published',
populate: { event: { fields: ['documentId'] } },
});
if (!registration) {
throw new Error('Registration not found');
}
const wasConfirmed = registration.registrationStatus === 'confirmed';
const eventDocumentId = registration.event?.documentId;
await strapi.documents('api::registration.registration').update({
documentId,
data: { registrationStatus: 'cancelled' },
status: 'published',
});
if (wasConfirmed && eventDocumentId) {
await this.promoteFromWaitlist(eventDocumentId);
}
return { cancelled: true };
},
}));The cancel method marks the registration cancelled, then promotes the next waitlisted attendee only if the cancelled spot was confirmed. After promotion, the remaining waitlist gets renumbered so positions stay sequential. The update call that flips status to confirmed triggers an entry.update webhook, which you'll use to send the promotion email. Note the use of limit: 1 for the findMany() call.
Expose cancellation through a controller action and route, following the same pattern as registration.
// src/api/registration/controllers/registration.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::registration.registration', ({ strapi }) => ({
async register(ctx) {
const { eventDocumentId, attendeeName, attendeeEmail } = ctx.request.body;
if (!eventDocumentId || !attendeeName || !attendeeEmail) {
return ctx.badRequest('eventDocumentId, attendeeName, and attendeeEmail are required');
}
const result = await strapi
.service('api::registration.registration')
.registerAttendee({ eventDocumentId, attendeeName, attendeeEmail });
const sanitized = await this.sanitizeOutput(result.registration, ctx);
ctx.body = {
data: sanitized,
status: result.status,
waitlistPosition: result.waitlistPosition,
};
},
async cancel(ctx) {
const { documentId } = ctx.params;
if (!documentId) {
return ctx.badRequest('documentId is required');
}
const result = await strapi
.service('api::registration.registration')
.cancelRegistration(documentId);
ctx.body = { data: result };
},
}));// src/api/registration/routes/01-custom-registration.js
module.exports = {
routes: [
{
method: 'POST',
path: '/registrations/register',
handler: 'registration.register',
config: {
auth: false,
},
},
{
method: 'POST',
path: '/registrations/:documentId/cancel',
handler: 'registration.cancel',
config: {
auth: false,
},
},
],
};Step 5: Sync the Count, then Configure Scheduling and Webhooks
The registrationCount field needs to stay accurate as registrations come and go. Model lifecycle hooks like afterCreate fire twice in Strapi 5 because the platform keeps both a draft and published version of each document. The recommended approach is a Document Service middleware, which runs exactly once per user-initiated operation regardless of internal database hook duplication.
Register the middleware in the register() lifecycle.
// src/index.ts
import type { Core } from '@strapi/strapi';
export default {
register({ strapi }: { strapi: Core.Strapi }) {
strapi.documents.use(async (context: any, next: any) => {
const result = await next();
const isRegistrationChange =
context.uid === 'api::registration.registration' &&
['create', 'update', 'delete'].includes(context.action);
if (isRegistrationChange) {
const eventDocumentId =
context.params?.data?.event?.connect?.[0]?.documentId ||
result?.event?.documentId;
if (eventDocumentId) {
const confirmedCount = await strapi
.documents('api::registration.registration')
.count({
filters: {
event: { documentId: eventDocumentId },
registrationStatus: { $eq: 'confirmed' },
},
status: 'published',
});
await strapi.documents('api::event.event').update({
documentId: eventDocumentId,
data: { registrationCount: confirmedCount },
status: 'published',
});
}
}
return result;
});
},
bootstrap({ strapi }: { strapi: Core.Strapi }) {},
};The middleware recounts confirmed registrations and writes the fresh number to the event whenever a registration is created or deleted. Because it counts only published registrations with status: 'published', the capacity check in your service stays accurate. Because cancellations are updates that flip registrationStatus to cancelled rather than deletes, the update branch handles the recount, counting only confirmed registrations. The typing uses Core.Strapi from @strapi/strapi instead of any for proper type safety.
Draft & Publish for scheduling. The schemas in Step 2 already enable draftAndPublish. This lets the content team draft upcoming events, refine the description and speaker lineup, then publish when registration should open. Until an event is published, it never appears on the public site because your frontend queries with status: 'published'. To enable it through the UI instead, open the Content-Type Builder, go to Advanced settings, tick Draft & Publish, and save. You can read more in the Draft & Publish documentation.
Webhooks for confirmation emails. Strapi fires webhooks on content events so you can hand off email delivery to a service like Resend, SendGrid, or Postmark. Configure a webhook in Settings → Webhooks in the admin panel. Point it at your email handler URL and select the events you care about:
entry.createfires when a new registration is created, so you can send the initial confirmation (either "you're confirmed" or "you're on the waitlist," based on theregistrationStatusin the payload).entry.updatefires when a waitlisted registration is promoted to confirmed, triggering the "a spot opened up" email.
A typical entry.create payload looks like this:
{
"event": "entry.create",
"createdAt": "2020-01-10T08:47:36.649Z",
"model": "registration",
"entry": {
"id": 1,
"attendeeName": "Jane Doe",
"registrationStatus": "confirmed",
"createdAt": "2020-01-10T08:47:36.264Z",
"updatedAt": "2020-01-10T08:47:36.264Z"
}
}Every webhook request includes an X-Strapi-Event header naming the event type, and private fields are never sent in the payload. One thing to keep in mind: webhooks do not fire for the User content type, by design, to avoid leaking user data. The Strapi webhooks documentation covers header configuration and the full event list.
Setting up the email handler is straightforward. Your handler reads the X-Strapi-Event header to know which event fired, then inspects the registrationStatus field on the entry to decide which template to send. A confirmed registration gets the "you're in" email; a waitlisted one gets the "you're on the list" email. Here's a pseudo-code route that branches on both signals.
app.post('/webhooks/strapi', async (req, res) => {
const eventType = req.headers['x-strapi-event'];
const { entry } = req.body;
if (eventType === 'entry.create') {
const template =
entry.registrationStatus === 'confirmed' ? 'confirmed' : 'waitlisted';
await sendEmail(entry.attendeeEmail, template, entry);
} else if (eventType === 'entry.update' && entry.registrationStatus === 'confirmed') {
await sendEmail(entry.attendeeEmail, 'spot-opened', entry);
}
res.sendStatus(200);
});Strapi 5 migration traps to remember. A few v4-to-v5 changes bite people building this stack. The REST response is now flat: attributes sit directly on each data item, so there is no data.attributes wrapper to unwrap. Reference documents by documentId instead of the numeric id, which is now only a database row identifier. Use the status parameter deliberately: Strapi 5 returns published content by default (equivalent to status=published), and status=draft should be specified only when draft content is intended. And in Next.js 16, async params must be awaited before you read route segments like the event slug.
Building the Next.js 16 Frontend
Step 1: Set Up the Next.js 16 Project
Create the frontend with the App Router and TypeScript.
npx create-next-app@latest webinar-frontend --typescript --eslint --app
cd webinar-frontend
npm install tailwindcss @tailwindcss/postcss postcss date-fns qsConfigure PostCSS for Tailwind v4.
// postcss.config.mjs
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;Import Tailwind in your global stylesheet. Version 4 replaces the three @tailwind directives with a single @import.
/* src/app/globals.css */
@import "tailwindcss";Add environment variables for your Strapi connection. Keep the API token unprefixed so it stays server-side only.
# .env
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-api-token
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337Generate the API token in Strapi under Settings → Global settings → API Tokens. A Custom token scoped to the registration and event endpoints is safer than Full access.
Set up a small fetch helper so you reuse the same headers everywhere.
// src/lib/strapi.ts
import qs from 'qs';
type StrapiQuery = Record<string, unknown>;
export async function fetchFromStrapi<T>(
path: string,
query: StrapiQuery = {},
options: RequestInit & { next?: { revalidate?: number } } = {}
): Promise<T> {
const queryString = qs.stringify(query, { encodeValuesOnly: true });
const url = `${process.env.STRAPI_URL}/api/${path}${queryString ? `?${queryString}` : ''}`;
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `bearer ${process.env.STRAPI_API_TOKEN}`,
...(options.headers || {}),
},
next: { revalidate: 60, ...(options.next || {}) },
});
if (!res.ok) {
throw new Error(`Strapi request failed: ${res.status}`);
}
return res.json();
}Step 2: Build the Public Event Listings
The listings page is a Server Component that fetches published events and renders a grid. Because Strapi 5 returns a flat response, you access fields directly on each data item, no data.attributes wrapper.
// src/app/events/page.tsx
import Link from 'next/link';
import { format } from 'date-fns';
import { fetchFromStrapi } from '@/lib/strapi';
type Speaker = {
documentId: string;
name: string;
title?: string;
company?: string;
};
type EventItem = {
documentId: string;
title: string;
slug: string;
eventType: string;
startDate: string;
capacity: number;
registrationCount: number;
isFeatured: boolean;
speakers: Speaker[];
};
function capacityBadge(event: EventItem) {
const remaining = event.capacity - event.registrationCount;
if (remaining <= 0) return { label: 'Waitlist only', className: 'bg-amber-100 text-amber-800' };
if (remaining <= event.capacity * 0.2) return { label: 'Filling up', className: 'bg-orange-100 text-orange-800' };
return { label: 'Open', className: 'bg-green-100 text-green-800' };
}
export async function generateMetadata() {
return {
title: 'Upcoming Webinars and Workshops',
description: 'Browse and register for upcoming webinars, workshops, and meetups.',
openGraph: {
title: 'Upcoming Webinars and Workshops',
description: 'Browse and register for upcoming events.',
type: 'website',
},
};
}
export default async function EventsPage() {
const { data } = await fetchFromStrapi<{ data: EventItem[] }>('events', {
populate: ['speakers'],
status: 'published',
sort: ['startDate:asc'],
});
const featured = data.filter((e) => e.isFeatured);
const rest = data.filter((e) => !e.isFeatured);
return (
<main className="mx-auto max-w-5xl px-4 py-10">
<h1 className="mb-8 text-3xl font-bold">Upcoming Events</h1>
{featured.length > 0 && (
<section className="mb-12">
{featured.map((event) => (
<Link
key={event.documentId}
href={`/events/${event.slug}`}
className="block rounded-xl bg-slate-900 p-8 text-white"
>
<span className="text-sm uppercase tracking-wide">{event.eventType}</span>
<h2 className="mt-2 text-2xl font-bold">{event.title}</h2>
<p className="mt-2 text-slate-300">
{format(new Date(event.startDate), "EEEE, MMMM d, yyyy 'at' h:mm a")}
</p>
</Link>
))}
</section>
)}
<section className="grid gap-6 md:grid-cols-2">
{rest.map((event) => {
const badge = capacityBadge(event);
return (
<Link
key={event.documentId}
href={`/events/${event.slug}`}
className="rounded-lg border border-slate-200 p-6 transition hover:shadow-md"
>
<div className="mb-3 flex items-center justify-between">
<span className="text-xs uppercase text-slate-500">{event.eventType}</span>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${badge.className}`}>
{badge.label}
</span>
</div>
<h3 className="text-lg font-semibold">{event.title}</h3>
<p className="mt-2 text-sm text-slate-600">
{format(new Date(event.startDate), "MMM d, yyyy 'at' h:mm a")}
</p>
{event.speakers.length > 0 && (
<p className="mt-3 text-sm text-slate-500">
{event.speakers.map((s) => s.name).join(', ')}
</p>
)}
</Link>
);
})}
</section>
</main>
);
}The capacity badge gives organizers and attendees a quick read on availability: open, filling up, or waitlist only. Featured events can be displayed at the top if custom code is added, but there is no default 'hero treatment' or enhanced styling applied to them.
Step 3: Build the Event Detail and Registration Page
The detail page needs the event data in two places: the generateMetadata function for SEO and the page component for rendering. Wrap the fetch in React cache so both calls share a single request and the data only loads once.
// src/app/events/[slug]/page.tsx
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { format } from 'date-fns';
import { fetchFromStrapi } from '@/lib/strapi';
import { RegistrationForm } from './registration-form';
type Speaker = {
documentId: string;
name: string;
bio?: string;
title?: string;
company?: string;
};
type EventDetail = {
documentId: string;
title: string;
slug: string;
eventType: string;
startDate: string;
duration?: number;
capacity: number;
registrationCount: number;
isWaitlistEnabled: boolean;
speakers: Speaker[];
};
const getEvent = cache(async (slug: string): Promise<EventDetail | null> => {
const { data } = await fetchFromStrapi<{ data: EventDetail[] }>('events', {
filters: { slug: { $eq: slug } },
populate: ['speakers', 'coverImage'],
status: 'published',
});
return data[0] ?? null;
});
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const event = await getEvent(slug);
if (!event) return { title: 'Event Not Found' };
const description = `Join ${event.title} on ${format(new Date(event.startDate), 'MMMM d, yyyy')}.`;
return {
title: event.title,
description,
openGraph: {
title: event.title,
description,
type: 'website',
},
};
}
export default async function EventPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const event = await getEvent(slug);
if (!event) notFound();
const remaining = event.capacity - event.registrationCount;
const isFull = remaining <= 0;
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<span className="text-sm uppercase tracking-wide text-slate-500">{event.eventType}</span>
<h1 className="mt-2 text-3xl font-bold">{event.title}</h1>
<p className="mt-3 text-slate-600">
{format(new Date(event.startDate), "EEEE, MMMM d, yyyy 'at' h:mm a")}
{event.duration ? ` · ${event.duration} min` : ''}
</p>
<p className="mt-4 text-sm font-medium">
{isFull
? `This event is full. Join the waitlist below.`
: `${remaining} ${remaining === 1 ? 'spot' : 'spots'} remaining`}
</p>
{event.speakers.length > 0 && (
<section className="mt-8">
<h2 className="text-xl font-semibold">Speakers</h2>
<div className="mt-4 space-y-4">
{event.speakers.map((speaker) => (
<div key={speaker.documentId} className="rounded-lg border border-slate-200 p-4">
<p className="font-medium">{speaker.name}</p>
{(speaker.title || speaker.company) && (
<p className="text-sm text-slate-500">
{[speaker.title, speaker.company].filter(Boolean).join(' · ')}
</p>
)}
{speaker.bio && <p className="mt-2 text-sm text-slate-600">{speaker.bio}</p>}
</div>
))}
</div>
</section>
)}
<section className="mt-10">
<h2 className="text-xl font-semibold">Register</h2>
<RegistrationForm eventDocumentId={event.documentId} isFull={isFull} />
</section>
</main>
);
}The fetch inside generateMetadata and getEvent is memoized by React.cache, so the page renders without double-fetching the event. The registration itself runs through a Server Action. Define the action first.
'use server';
// src/app/events/[slug]/actions.ts
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const schema = z.object({
eventDocumentId: z.string().min(1),
attendeeName: z.string().min(1),
attendeeEmail: z.string().email(),
});
type RegisterState = {
message: string | null;
error: string | null;
status?: 'confirmed' | 'waitlisted';
waitlistPosition?: number | null;
};
export async function registerForEvent(
prevState: RegisterState,
formData: FormData
): Promise<RegisterState> {
const parsed = schema.safeParse({
eventDocumentId: formData.get('eventDocumentId'),
attendeeName: formData.get('attendeeName'),
attendeeEmail: formData.get('attendeeEmail'),
});
if (!parsed.success) {
return { error: 'Invalid form data', message: null };
}
const res = await fetch(`${process.env.STRAPI_URL}/api/registrations/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `bearer ${process.env.STRAPI_API_TOKEN}`,
},
body: JSON.stringify(parsed.data),
});
if (!res.ok) {
return { error: 'Registration failed. Please try again.', message: null };
}
const result = await res.json();
revalidatePath('/events');
if (result.status === 'waitlisted') {
return {
message: `You're on the waitlist at position ${result.waitlistPosition}.`,
error: null,
status: 'waitlisted',
waitlistPosition: result.waitlistPosition,
};
}
return {
message: "You're registered. Check your email for confirmation.",
error: null,
status: 'confirmed',
};
}The action validates input with Zod, posts to your custom registration endpoint, and returns a message based on whether the attendee was confirmed or waitlisted. The form component wires this up with the useActionState hook, which React 19 provides.
'use client';
// src/app/events/[slug]/registration-form.tsx
import { useActionState } from 'react';
import { registerForEvent } from './actions';
const initialState = { message: null, error: null };
export function RegistrationForm({
eventDocumentId,
isFull,
}: {
eventDocumentId: string;
isFull: boolean;
}) {
const [state, formAction, isPending] = useActionState(registerForEvent, initialState);
return (
<form action={formAction} className="mt-4 space-y-4">
<input type="hidden" name="eventDocumentId" value={eventDocumentId} />
<div>
<label htmlFor="attendeeName" className="block text-sm font-medium">
Name
</label>
<input
id="attendeeName"
name="attendeeName"
type="text"
required
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="attendeeEmail" className="block text-sm font-medium">
Email
</label>
<input
id="attendeeEmail"
name="attendeeEmail"
type="email"
required
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</div>
<button
type="submit"
disabled={isPending}
className="rounded-md bg-slate-900 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? 'Submitting...' : isFull ? 'Join Waitlist' : 'Register'}
</button>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
{state.message && <p className="text-sm text-green-700">{state.message}</p>}
</form>
);
}The isPending flag from useActionState disables the button during submission, and the button label switches between "Submitting...", "Join Waitlist", and "Register" based on the pending state and capacity.
Step 4: Build the Attendee Dashboard
Attendees need a view of their registrations with the option to cancel. For this tutorial, the dashboard looks up registrations by email. In production, gate this behind authentication so attendees only see their own entries.
// src/app/dashboard/page.tsx
import { fetchFromStrapi } from '@/lib/strapi';
import { format } from 'date-fns';
import { CancelButton } from './cancel-button';
type Registration = {
documentId: string;
attendeeName: string;
registrationStatus: 'confirmed' | 'waitlisted' | 'cancelled';
waitlistPosition?: number | null;
event: {
title: string;
slug: string;
startDate: string;
};
};
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ email?: string }>;
}) {
const { email } = await searchParams;
if (!email) {
return (
<main className="mx-auto max-w-2xl px-4 py-10">
<h1 className="text-2xl font-bold">Your Registrations</h1>
<p className="mt-4 text-slate-600">
Add <code>?email=you@example.com</code> to the URL to view your registrations.
</p>
</main>
);
}
const { data } = await fetchFromStrapi<{ data: Registration[] }>(
'registrations',
{
filters: { attendeeEmail: { $eq: email } },
populate: { event: { fields: ['title', 'slug', 'startDate'] } },
status: 'published',
sort: ['registeredAt:desc'],
},
{ next: { revalidate: 0 } }
);
return (
<main className="mx-auto max-w-2xl px-4 py-10">
<h1 className="text-2xl font-bold">Your Registrations</h1>
<div className="mt-6 space-y-4">
{data.map((reg) => (
<div key={reg.documentId} className="rounded-lg border border-slate-200 p-4">
<h2 className="font-semibold">{reg.event?.title}</h2>
<p className="text-sm text-slate-500">
{reg.event?.startDate &&
format(new Date(reg.event.startDate), "MMM d, yyyy 'at' h:mm a")}
</p>
<p className="mt-2 text-sm">
Status: <span className="font-medium">{reg.registrationStatus}</span>
{reg.registrationStatus === 'waitlisted' && reg.waitlistPosition
? ` (position ${reg.waitlistPosition})`
: ''}
</p>
{reg.registrationStatus !== 'cancelled' && (
<CancelButton documentId={reg.documentId} />
)}
</div>
))}
</div>
</main>
);
}The cancel button calls a Server Action that hits the cancel endpoint, which triggers waitlist promotion in Strapi.
'use client';
// src/app/dashboard/cancel-button.tsx
import { useTransition } from 'react';
import { cancelRegistration } from './actions';
export function CancelButton({ documentId }: { documentId: string }) {
const [isPending, startTransition] = useTransition();
function handleCancel() {
if (!confirm('Cancel this registration?')) return;
startTransition(() => cancelRegistration(documentId));
}
return (
<button
onClick={handleCancel}
disabled={isPending}
className="mt-3 text-sm text-red-600 underline disabled:opacity-50"
>
{isPending ? 'Cancelling...' : 'Cancel registration'}
</button>
);
}'use server';
// src/app/dashboard/actions.ts
import { revalidatePath } from 'next/cache';
export async function cancelRegistration(documentId: string) {
await fetch(`${process.env.STRAPI_URL}/api/registrations/${documentId}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
});
revalidatePath('/dashboard');
}A confirm() dialog asks for confirmation before cancellation, and the action calls revalidatePath('/dashboard') after posting to Strapi.
Putting It All Together
Time to verify the whole flow works end to end. Create a test event in the Strapi admin with capacity set to three, fill in the title, date, and event type, then publish it.
Register three attendees through the event detail page. Each one should come back confirmed, and the event's registrationCount should climb to three as the Document Service middleware recounts after every registration. Check the event in the admin to confirm the count.
Register a fourth attendee. Because registrationCount (3) is no longer less than capacity (3), the service creates a waitlisted registration and returns waitlist position one. The form shows "You're on the waitlist at position one."
Head to the dashboard for one of the three confirmed attendees and cancel their registration. Two things happen inside Strapi: the cancelled registration flips to cancelled, and cancelRegistration calls promoteFromWaitlist, which finds the waitlisted attendee, updates their status to confirmed, and clears their waitlist position. The Document Service middleware fires again on the update and recounts, keeping registrationCount accurate.
Watch your webhook receiver during all of this. You should see entry.create fire for each new registration, with registrationStatus in the payload telling you whether to send a confirmation or a waitlist email. When the waitlisted attendee gets promoted, the entry.update event fires. An entry.update payload looks like this:
{
"event": "entry.update",
"createdAt": "2020-01-10T09:12:18.114Z",
"model": "registration",
"entry": {
"id": 4,
"attendeeName": "John Smith",
"registrationStatus": "confirmed",
"createdAt": "2020-01-10T08:55:02.264Z",
"updatedAt": "2020-01-10T09:12:18.001Z"
}
}Match on the status transition from waitlisted to confirmed to send the "a spot opened up" email. If you want to inspect payloads quickly during development, point the webhook at a temporary endpoint from a service like webhook.site.
Next Steps
You have a working registration platform, but there's room to grow it into something production teams rely on.
- Deploy it. Push your Strapi project to Strapi Cloud and deploy the Next.js frontend to Vercel through a Git import. Remember to add your Vercel domain to Strapi's CORS configuration and set the production
STRAPI_URLandSTRAPI_API_TOKENenvironment variables. - Generate calendar invites. Produce
.icsfiles in the iCalendar format so confirmed attendees can add events to their calendars in one click. Attach the file to the confirmation email by generating it inside the same webhook handler that fires onentry.create. - Wire up meeting links. Integrate Zoom or Google Meet link generation so each event ships with a join URL. Generate the link at confirmation time and store it on the registration so every attendee gets a unique, trackable join URL.
- Add recurring events. Model a recurrence pattern on the Event type and generate child instances on a schedule. A nightly job can read the pattern, create the next batch of event documents as drafts, and leave publishing to the organizer.
- Collect post-event feedback. Add a feedback form that opens after the event ends, tied to confirmed registrations. Trigger the feedback request email from a scheduled task that checks for events whose
startDateplus duration has passed.
For deeper reference, the Strapi documentation covers the Document Service API, webhooks, and deployment in detail, and the Next.js documentation is the source of truth for Server Actions, metadata, and caching.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.