Book through a server action
With @clndr-pro/react, the guest's browser calls clndr.pro directly with a publishable key. That's the fastest route, but your server never sees the booking happen. Route it through a server action instead and you get a place to:
- check a captcha or rate-limit by IP before anything reaches clndr.pro,
- write the lead to your CRM or database in the same request,
- keep every key off the client: the page below ships no clndr.pro key at all.
The trade: you render the slot picker yourself. This tutorial builds /book in a Next.js 15 or 16 App Router app with a day picker, a slot list, the host's questions and a confirmation screen. It assumes shadcn/ui and zod:
npm install @clndr-pro/sdk server-only zod
npx shadcn@latest add button input label textareaCLNDR_SECRET_KEY=clndr_sk_...The client
One module owns the key. server-only makes any import of it from a client component a build error.
import 'server-only';
import { Clndr } from '@clndr-pro/sdk';
export const clndr = new Clndr(process.env.CLNDR_SECRET_KEY!);
/** The booking page this site books into. */
export const BOOKING_SLUG = 'intro-call';The page
A Server Component loads the booking page and the slots for the day in ?date=. Days are calendar days in the host's timezone, because that's how the slots endpoint reads date.
import Link from 'next/link';
import { clndr, BOOKING_SLUG } from '@/lib/clndr';
import { BookingForm } from './booking-form';
export const metadata = { title: 'Book a call' };
/** YYYY-MM-DD for `date` as seen in `timeZone`. */
function dayIn(timeZone: string, date = new Date()) {
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
}
function addDays(ymd: string, n: number) {
const [y, m, d] = ymd.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
}
function dayLabel(ymd: string) {
return new Date(`${ymd}T12:00:00Z`).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
}
export default async function BookPage({ searchParams }: { searchParams: Promise<{ date?: string }> }) {
const { bookingPage, userProfile, questions } = await clndr.bookingPages.get(BOOKING_SLUG);
const today = dayIn(userProfile.timezone ?? 'UTC');
const horizon = Math.min(bookingPage.max_days_ahead || 14, 14);
const days = Array.from({ length: horizon }, (_, i) => addDays(today, i));
const { date: requested } = await searchParams;
const date = requested && days.includes(requested) ? requested : days[0];
const slots = await clndr.bookingPages.getSlots(BOOKING_SLUG, date);
return (
<main className="mx-auto max-w-2xl space-y-8 px-4 py-12">
<header className="space-y-1">
<h1 className="text-2xl font-semibold">{bookingPage.title}</h1>
<p className="text-sm text-muted-foreground">
{userProfile.full_name ?? userProfile.username} · {bookingPage.duration_minutes} min
</p>
</header>
<nav aria-label="Pick a day" className="flex gap-2 overflow-x-auto pb-1">
{days.map((d) => (
<Link
key={d}
href={`/book?date=${d}`}
scroll={false}
aria-current={d === date ? 'date' : undefined}
className="shrink-0 rounded-md border px-3 py-2 text-sm aria-[current=date]:border-primary aria-[current=date]:font-medium"
>
{dayLabel(d)}
</Link>
))}
</nav>
{/* key: a new day remounts the form, clearing the picked slot */}
<BookingForm key={date} date={date} slots={slots} questions={questions} />
</main>
);
}max_days_ahead caps the strip, and slots that already started on today aren't returned, so the first pill can legitimately be empty in the evening.
The action
The action validates the form, re-checks that the slot is still open (someone may have taken it while the guest typed), and books. Errors come back as state for the form to show.
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { ClndrError } from '@clndr-pro/sdk';
import { clndr, BOOKING_SLUG } from '@/lib/clndr';
export type BookingState =
| { status: 'idle' }
| { status: 'error'; message?: string; fieldErrors?: Record<string, string>; values?: Record<string, string> }
| { status: 'success'; bookingType: 'direct' | 'approval'; startTime: string; guestEmail: string };
const schema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
slot: z.string().regex(/^[^|]+\|[^|]+$/, 'Pick a time.'),
guestName: z.string().trim().min(1, 'Enter your name.').max(120),
guestEmail: z.string().trim().email('Enter a valid email address.'),
});
const TAKEN = 'That time was just taken. Pick another one.';
export async function bookSlot(_prev: BookingState, formData: FormData): Promise<BookingState> {
const values = Object.fromEntries(
[...formData.entries()].filter((e): e is [string, string] => typeof e[1] === 'string'),
);
const parsed = schema.safeParse(values);
if (!parsed.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of parsed.error.issues) fieldErrors[String(issue.path[0])] ??= issue.message;
return { status: 'error', fieldErrors, values };
}
const { date, slot, guestName, guestEmail } = parsed.data;
const [startTime, endTime] = slot.split('|');
// Your own gate goes here: verify a Turnstile/hCaptcha token, rate-limit by
// IP (headers().get('x-forwarded-for')) with Upstash or your database,
// reject disposable email domains, …
const { bookingPage, questions } = await clndr.bookingPages.get(BOOKING_SLUG);
// The API doesn't enforce required questions, so this form does.
const responses: { questionId: string; answer: string }[] = [];
const fieldErrors: Record<string, string> = {};
for (const q of questions) {
const raw = formData.get(`q:${q.id}`);
const answer = q.question_type === 'checkbox' ? (raw ? 'Yes' : '') : typeof raw === 'string' ? raw.trim() : '';
if (answer) responses.push({ questionId: q.id, answer });
else if (q.is_required) fieldErrors[`q:${q.id}`] = 'This one is required.';
}
if (Object.keys(fieldErrors).length) return { status: 'error', fieldErrors, values };
// Still open? Compare instants, not strings.
const open = await clndr.bookingPages.getSlots(BOOKING_SLUG, date);
const stillOpen = open.some(
(s) => Date.parse(s.start) === Date.parse(startTime) && Date.parse(s.end) === Date.parse(endTime),
);
if (!stillOpen) return { status: 'error', message: TAKEN, values };
try {
await clndr.bookings.create({
bookingPageId: bookingPage.id,
guestName,
guestEmail,
startTime,
endTime,
responses,
});
} catch (err) {
if (err instanceof ClndrError) {
if (err.message.startsWith('This time slot')) return { status: 'error', message: TAKEN, values };
if (err.status === 429 || err.message.startsWith('Too many')) {
return { status: 'error', message: 'Too many booking attempts from this email. Try again after the top of the hour.', values };
}
console.error('clndr.pro booking failed', err.status, err.message);
} else {
console.error('clndr.pro booking failed', err);
}
return { status: 'error', message: "We couldn't book that time. Please try again.", values };
}
// Booked. Write the lead to your CRM here if you keep one.
revalidatePath('/book');
return { status: 'success', bookingType: bookingPage.booking_type, startTime, guestEmail };
}The two startsWith checks match the API's messages for a taken slot and for a guest email over its hourly limit. Errors and rate limits lists them all.
The form
A client component renders slots and questions, posts to the action with useActionState, and shows either the errors or the confirmation. Times are formatted in the guest's own timezone, which only the browser knows, so those labels skip hydration checks.
'use client';
import { useActionState } from 'react';
import type { BookingQuestion, TimeSlot } from '@clndr-pro/sdk';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { bookSlot, type BookingState } from './actions';
const time = (iso: string) => new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const when = (iso: string) =>
new Date(iso).toLocaleString([], { weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' });
export function BookingForm({ date, slots, questions }: { date: string; slots: TimeSlot[]; questions: BookingQuestion[] }) {
const [state, action, pending] = useActionState<BookingState, FormData>(bookSlot, { status: 'idle' });
if (state.status === 'success') {
return (
<div role="status" className="space-y-2 rounded-lg border p-6">
<h2 className="text-lg font-semibold">
{state.bookingType === 'approval' ? 'Request sent' : "You're booked"}
</h2>
<p className="text-sm text-muted-foreground" suppressHydrationWarning>
{when(state.startTime)}.{' '}
{state.bookingType === 'approval'
? `You'll get an email at ${state.guestEmail} once it's approved.`
: `A confirmation is on its way to ${state.guestEmail}.`}
</p>
</div>
);
}
const err = state.status === 'error' ? state : undefined;
const value = (name: string) => err?.values?.[name];
const fieldError = (name: string) => {
const message = err?.fieldErrors?.[name];
return message ? <p className="text-sm text-destructive">{message}</p> : null;
};
return (
<form action={action} className="space-y-6">
<input type="hidden" name="date" value={date} />
<fieldset className="space-y-3">
<legend className="text-sm font-medium">Time</legend>
{slots.length === 0 ? (
<p className="text-sm text-muted-foreground">No open times this day. Try another one.</p>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{slots.map((s) => {
const v = `${s.start}|${s.end}`;
return (
<label
key={s.start}
className="cursor-pointer rounded-md border px-3 py-2 text-center text-sm has-[:checked]:border-primary has-[:checked]:bg-primary has-[:checked]:text-primary-foreground has-[:focus-visible]:ring-2"
>
<input type="radio" name="slot" value={v} defaultChecked={value('slot') === v} required className="sr-only" />
<span suppressHydrationWarning>{time(s.start)}</span>
</label>
);
})}
</div>
)}
{fieldError('slot')}
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="guestName">Name</Label>
<Input id="guestName" name="guestName" autoComplete="name" defaultValue={value('guestName')} required />
{fieldError('guestName')}
</div>
<div className="space-y-2">
<Label htmlFor="guestEmail">Email</Label>
<Input id="guestEmail" name="guestEmail" type="email" autoComplete="email" defaultValue={value('guestEmail')} required />
{fieldError('guestEmail')}
</div>
</div>
{questions.map((q) => {
const name = `q:${q.id}`;
const required = q.is_required ?? false;
return (
<div key={q.id} className="space-y-2">
{q.question_type === 'checkbox' ? (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name={name} defaultChecked={!!value(name)} required={required} />
{q.question_text}
</label>
) : (
<>
<Label htmlFor={name}>
{q.question_text}
{required ? ' *' : ''}
</Label>
{q.question_type === 'textarea' ? (
<Textarea id={name} name={name} defaultValue={value(name)} required={required} />
) : q.question_type === 'select' ? (
<select
id={name}
name={name}
defaultValue={value(name) ?? ''}
required={required}
className="h-9 w-full rounded-md border bg-transparent px-3 text-sm"
>
<option value="">Choose…</option>
{(q.options ?? []).map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
) : (
<Input
id={name}
name={name}
type={q.question_type === 'email' ? 'email' : q.question_type === 'phone' ? 'tel' : 'text'}
defaultValue={value(name)}
required={required}
/>
)}
</>
)}
{fieldError(name)}
</div>
);
})}
{err?.message ? (
<p role="alert" className="text-sm text-destructive">
{err.message}
</p>
) : null}
<Button type="submit" disabled={pending || slots.length === 0} className="w-full">
{pending ? 'Booking…' : 'Book it'}
</Button>
</form>
);
}Returning values from the action matters: React resets a form after its action runs, and the inputs fall back to their defaultValue. Without it, a typo in the email would wipe the whole form.
Try it
Open /book, pick a day and a time, and submit. Then try the failure paths: submit with an empty name, or book the same slot from two tabs. The second tab gets "That time was just taken" and a fresh slot list after it picks another day. The booking shows up under Meetings in the dashboard.
Book through a Next.js server action
Build a /book page in this Next.js App Router project that books clndr.pro meetings through a server action, so the browser never calls clndr.pro and no clndr.pro key reaches the client.
Read first: https://docs.clndr.pro/tutorials/server-actions.md and https://docs.clndr.pro/sdk.md
Facts:
- @clndr-pro/sdk 0.1.5+; client in lib/clndr.ts with `import 'server-only'` and new Clndr(process.env.CLNDR_SECRET_KEY!). Never use NEXT_PUBLIC_ for the secret key.
- API base is https://www.clndr.pro/api/v1 (the SDK default from 0.1.5).
- clndr.bookingPages.get(slug) -> { bookingPage, userProfile, questions }. clndr.bookingPages.getSlots(slug, 'YYYY-MM-DD') -> [{ start, end }]; the date is a calendar day in the host's timezone (userProfile.timezone) and past slots are excluded.
- clndr.bookings.create({ bookingPageId, guestName, guestEmail, startTime, endTime, responses: [{ questionId, answer }] }). Send slot start/end unchanged. booking_type 'approval' means the booking is pending, so say "Request sent", not "Booked".
- The API doesn't enforce required questions: validate in the action. Re-check the slot is still in getSlots() right before create. Errors throw ClndrError { status, message }; a taken slot's message starts with "This time slot".
Steps:
1. Ask me for the booking page slug. Check whether shadcn/ui and zod are set up; use them if so.
2. Create lib/clndr.ts, app/book/page.tsx (server component: day strip from ?date=, slots), app/book/actions.ts ('use server', zod validation, required questions, slot re-check, create, revalidatePath) and app/book/booking-form.tsx ('use client', useActionState, slot radios, the page's questions by question_type, success screen). Return submitted values on error so the form keeps them.
3. Add CLNDR_SECRET_KEY to .env.example and tell me to set it in .env.local.
4. Run the type checker and build; fix what breaks.
Done when: no file under a 'use client' boundary imports lib/clndr.ts, slot times show in the guest's local timezone, and a taken slot shows a clear message instead of a crash.