# Book through a server action

Build a Next.js booking page where the browser only talks to your server, which validates the request and books it with your secret key.

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](https://zod.dev):

```bash
npm install @clndr-pro/sdk server-only zod
npx shadcn@latest add button input label textarea
```

```bash title=".env.local"
CLNDR_SECRET_KEY=clndr_sk_...
```

```mermaid title="One booking, end to end"
sequenceDiagram
  participant G as Guest's browser
  participant N as Your Next.js server
  participant C as clndr.pro API
  G->>N: GET /book?date=2026-10-15
  N->>C: GET /booking-pages/intro-call, /slots?date=2026-10-15
  C-->>N: page, questions, open slots
  N-->>G: HTML with the slot list
  G->>N: submit form (server action)
  N->>N: validate, captcha, your own checks
  N->>C: GET /slots again (still open?)
  N->>C: POST /bookings (secret key)
  C-->>N: 201 booking
  N-->>G: confirmation screen
```

## The client

One module owns the key. `server-only` makes any import of it from a client component a build error.

```ts title="lib/clndr.ts"
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`.

```tsx title="app/book/page.tsx"
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.

```ts title="app/book/actions.ts"
'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](/errors) 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.

```tsx title="app/book/booking-form.tsx"
'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**](https://www.clndr.pro/meetings) in the dashboard.

**Book through a Next.js server action**

```text
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.
```
