A booking site has three jobs: show what's available, take a reservation, and remind people they made one. AppEngine ships the data layer (events, reservations, slot generation, queue) and a Next.js app does the rest. This tutorial walks through the same wiring yugo uses, narrowed to the parts you'll keep.
What you're building
- Service catalog driven by
crm/reservations/definitions - Live availability via
crm/reservations/slots - Reservation creation against
crm/reservations/create - Optional walk-in queue via
crm/service-request-queue/join - Confirmation email + ICS attachment fired by AppEngine workflows
The reservation system is one of the AppEngine CRM sub-modules. A definition is a bookable thing (a haircut, a table, a room). It has services, locations, attendants (the staff serving the reservation), and a settings object describing buffers, cancellation rules, and capacity.
Prerequisites
- An AppEngine instance and an org id (signup)
- Node 20+, a fresh Next.js 16 app
- An admin login or API key with reservation permissions
npx create-next-app@latest booking-site --app --ts --tailwind
cd booking-site
npm install
Then drop in the AppEngine client. yugo's src/lib/ is the canonical pattern — copy appmint-client.ts, appmint-config.ts, appmint-endpoints.ts, proxy-utils.ts from there, or follow Add auth to a Next.js app.
The pattern below assumes the AppEngine client functions are already wired. If not, do the auth tutorial first — every endpoint here uses the same processRequest helper.
Step-by-step
- 1
Define a reservable service
In the admin UI, open CRM → Reservations → Definitions, or
POSTdirectly tocrm/reservations/create-definition. A minimum definition:// POST /crm/reservations/create-definition { "data": { "name": "Haircut", "description": "30-minute appointment", "services": [ { "id": "s1", "name": "Trim", "duration": 30, "price": 35 }, { "id": "s2", "name": "Full cut + style", "duration": 60, "price": 70 } ], "locations": [ { "id": "loc1", "name": "Downtown", "address": "100 Main St" } ], "attendants": [ { "id": "a1", "name": "Sam", "serviceIds": ["s1", "s2"], "locationIds": ["loc1"] } ], "settings": { "bufferBefore": 5, "bufferAfter": 5, "cancellationPolicy": "24h", "maxAdvance": 30 } } }Each definition gets an id you'll use in every subsequent call.
- 2
Build a reservation API client
Copy
yugo/src/modules/reservation-system/api/reservation-api.tsinto your project undersrc/modules/reservation-system/api/. The file is self-contained and only importsgetAppEngineClientfrom your shared lib. The endpoints it wraps:// src/modules/reservation-system/api/reservation-api.ts (excerpted) const RESERVATION_ENDPOINTS = { SLOTS: 'crm/reservations/slots', DEFINITIONS: 'crm/reservations/definitions', CREATE: 'crm/reservations/create', UPDATE: 'crm/reservations/update', DELETE: 'crm/reservations/delete', GET: 'crm/reservations/get', CANCEL: 'crm/reservations/cancel', QUEUE_JOIN: 'crm/service-request-queue/join', };Expose typed helpers per call:
getDefinitions,getAvailability,createReservation,cancelReservation. Never let UI code touch raw URLs. - 3
Render the service picker
A booking flow is a four-step funnel: pick service → pick attendant (optional) → pick slot → confirm. The first step lists services from the definition.
// src/app/booking/page.tsx import { getReservationAPI } from '@/modules/reservation-system/api'; export default async function BookingPage() { const api = getReservationAPI(); const definition = await api.getDefinition(process.env.RESERVATION_DEFINITION_ID!); return ( <div className="mx-auto max-w-2xl py-12"> <h1 className="text-2xl font-semibold">Book a {definition.name.toLowerCase()}</h1> <ul className="mt-6 grid gap-4"> {definition.services?.map((s) => ( <li key={s.id}> <a href={`/booking/${s.id}`} className="block rounded-lg border p-4 hover:border-blue-500" > <div className="font-medium">{s.name}</div> <div className="text-sm text-gray-500"> {s.duration} min · ${s.price} </div> </a> </li> ))} </ul> </div> ); }This is a server component — the call happens once, on the server, with the AppEngine session attached via cookie/JWT.
- 4
Fetch and display available slots
The slots endpoint takes a definition id, a service id, an optional attendant id, and a date range. AppEngine returns an array of
{ startTime, endTime, available, attendantId }.// src/components/SlotPicker.tsx — client component 'use client'; import { useEffect, useState } from 'react'; import { getReservationAPI } from '@/modules/reservation-system/api'; export function SlotPicker({ definitionId, serviceId, date, onSelect, }: { definitionId: string; serviceId: string; date: string; onSelect: (slot: TimeSlot) => void; }) { const [slots, setSlots] = useState<TimeSlot[]>([]); const [loading, setLoading] = useState(true); useEffect(() => { getReservationAPI() .getAvailability({ definitionId, serviceId, date }) .then((s) => { setSlots(s.filter((slot) => slot.available)); }) .finally(() => setLoading(false)); }, [definitionId, serviceId, date]); if (loading) return <p>Checking availability...</p>; return ( <div className="grid grid-cols-3 gap-2"> {slots.map((slot) => ( <button key={slot.startTime} onClick={() => onSelect(slot)} className="rounded border px-3 py-2 text-sm hover:bg-blue-50" > {new Date(slot.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', })} </button> ))} </div> ); }The slot endpoint enforces every constraint defined on the definition: existing reservations, attendant calendars, business hours, holidays, and per-slot capacity. Don't reimplement that logic on the client.
- 5
Submit the reservation
When the user picks a slot and fills the customer-info form, send the whole package to
crm/reservations/create. The customer doesn't need to be authenticated — AppEngine creates an anonymous customer record on first booking and links it by email.// src/modules/reservation-system/actions.ts 'use server'; import { getReservationAPI } from './api'; export async function bookReservation(formData: FormData) { const api = getReservationAPI(); const reservation = await api.createReservation({ definitionId: formData.get('definitionId') as string, serviceId: formData.get('serviceId') as string, attendantId: formData.get('attendantId') as string | undefined, locationId: formData.get('locationId') as string, startTime: formData.get('startTime') as string, customer: { name: formData.get('customerName') as string, email: formData.get('customerEmail') as string, phone: formData.get('customerPhone') as string, }, notes: formData.get('notes') as string | undefined, }); return reservation; }The response includes
id,confirmationCode, andstate(usuallypendingorconfirmeddepending on definition settings). Pipe the user to a confirmation page using the id. - 6
Build the confirmation + manage page
Reservations come back wrapped in the standard
BaseModel. Thedatapayload hasstate,startTime,endTime,customer,confirmationCode. Show those, plus a cancel button.// src/app/booking/[id]/page.tsx import { getReservationAPI } from '@/modules/reservation-system/api'; export default async function ConfirmationPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const r = await getReservationAPI().getReservation(id); return ( <div className="mx-auto max-w-md py-12"> <h1 className="text-xl font-semibold">You're booked</h1> <p className="mt-2 text-gray-600">Confirmation: {r.data.confirmationCode}</p> <dl className="mt-6 space-y-2"> <div> <dt className="text-sm text-gray-500">When</dt> <dd>{new Date(r.data.startTime).toLocaleString()}</dd> </div> <div> <dt className="text-sm text-gray-500">Where</dt> <dd>{r.data.location?.name}</dd> </div> </dl> </div> ); }A
cancelaction callscrm/reservations/cancel. AppEngine fires areservation.cancelledevent, which a workflow can pick up to send a refund or notification. - 7
Walk-in queue (optional)
If the venue takes walk-ins, swap the slot picker for a queue form on busy days. The queue endpoint is
crm/service-request-queue/join:await api.joinQueue({ definitionId, serviceId, customer: { name, email, phone }, estimatedWait: 25, // minutes; AppEngine recalculates as queue moves });AppEngine emits position updates over the chat WebSocket. Subscribe to those in a client component to give the user a live "you're #4 of 7" view.
- 8
Add reminders
The reservation create response triggers a
reservation.createdevent. In the admin UI, open Automation → Workflows and create a workflow with that trigger. Add a delayed step ("24 hours beforedata.startTime") that sends an email and an SMS through Twilio. AppEngine handles the scheduling — you don't need a cron in your Next.js app.See Automation workflows for the full event catalog.
What's next
The yugo source has a full polished version of this flow including a calendar UI, demo accounts, and self-check-in screens. Worth reading in order:
src/modules/reservation-system/index.tsx— top-level shellsrc/modules/reservation-system/reservation-calendar.tsx— month/week viewsrc/modules/reservation-system/walk-in-queue.tsx— live queue UIsrc/modules/reservation-system/payment-checkout.tsx— Stripe handoff for paid reservations
For paid bookings, splice in the storefront checkout tutorial — the reservation id becomes the order's metadata.reservationId so the workflow that confirms the booking can wait for payment.succeeded.