# clndr.pro --- # Introduction clndr.pro hosts booking pages. A host sets their weekly hours, a guest picks an open slot, and the meeting shows up in the host's dashboard (and on their Google Calendar with a Meet link, when the page has Meet links turned on). These docs cover running that booking flow inside your own site, so guests never leave it for `www.clndr.pro/ada/intro-call`. This is a complete booking form in a React app: ```tsx title="app/book/booking-widget.tsx" 'use client'; import { ClndrProvider, BookingInline } from '@clndr-pro/react'; export function BookingWidget() { return ( ); } ``` It fetches the page, lets the guest pick a date and a slot, asks the host's questions, and books. The [quickstart](/quickstart) gets it running in a Next.js app. ## Pick a way in | You want | Use | Key | | --- | --- | --- | | A booking widget on a site you don't write code for (Webflow, WordPress, plain HTML) | [Embed script](/embed/script) | None | | A booking page in an iframe, nothing else | [Iframe embed](/embed/iframe) | None | | The booking form inside your React or Next.js app, in your own components | [`@clndr-pro/react`](/react/components) | Publishable | | Your own booking UI, or bookings flowing into your backend | [REST API](/api-reference) or [`@clndr-pro/sdk`](/sdk) | Publishable in the browser, secret on the server | [Choose an integration](/integration-options) compares them properly, including what each one can't do. ## What the API covers From any key you can read your booking pages, the questions they ask, and the open slots on a given day, and you can create bookings. From a secret key on your server you can also list bookings, confirm the ones waiting for approval, and cancel them. Creating or editing booking pages, availability and access codes happens in the [dashboard](https://www.clndr.pro/booking), not the API. There are no outbound webhooks yet: your code hears about new bookings from the embed's browser events, from your own booking handler, or by [polling the API](/tutorials/sync-bookings). ## Building with Claude Code Every page here is also plain Markdown (add `.md` to the URL), the whole site is indexed at [`/llms.txt`](/llms.txt), and there's an MCP server and a Claude Code skill. [Docs for AI tools](/ai/tools) has the setup. If you just want it done, paste this into Claude Code from the root of your project: ```text Add clndr.pro booking to this project. Read these first. They are Markdown pages written for coding agents: - https://docs.clndr.pro/llms.txt (index of every docs page) - https://docs.clndr.pro/integration-options.md - https://docs.clndr.pro/nextjs.md for a Next.js app, otherwise https://docs.clndr.pro/react/components.md or, for a site without React, https://docs.clndr.pro/embed/script.md Facts to rely on: - API base URL: https://www.clndr.pro/api/v1. Always the www host: the bare domain redirects and the redirect drops the Authorization header. - Packages: @clndr-pro/react (components and hooks, browser) and @clndr-pro/sdk (typed client, mainly server). Use @clndr-pro/react 0.2.1+ and @clndr-pro/sdk 0.1.5+. With older versions, pass baseUrl "https://www.clndr.pro". - Import @clndr-pro/react only from files marked 'use client'. - Publishable key (clndr_pk_...) goes in NEXT_PUBLIC_CLNDR_PUBLISHABLE_KEY. It is browser-safe and can read booking pages and slots and create bookings. - Secret key (clndr_sk_...) goes in CLNDR_SECRET_KEY. Server only. It can also list, confirm and cancel bookings. Never import it into client code. Steps: 1. Look at the project: framework and router, styling (Tailwind? shadcn/ui under components/ui?), and where a booking page fits. Tell me what you found and which integration you'll use before you change files. 2. Ask me for the booking page slug. Ask for my clndr.pro username too if you pick an embed. 3. Add the env vars to .env.example with placeholder values and tell me to put the real keys in .env.local. 4. Build it with the project's existing components and styles. 5. Run the type checker and the build, and fix what breaks. ``` [Prompts for Claude Code](/ai/claude-code) has more specific ones: a shadcn booking widget, an approvals screen, syncing bookings to a database. ## Where things are | | | | --- | --- | | Dashboard | [www.clndr.pro](https://www.clndr.pro/dashboard): **Booking pages**, **Availability** for hours, **API keys** | | API base URL | `https://www.clndr.pro/api/v1` | | npm packages | [`@clndr-pro/react`](https://www.npmjs.com/package/@clndr-pro/react), [`@clndr-pro/sdk`](https://www.npmjs.com/package/@clndr-pro/sdk) | | OpenAPI 3.1 spec | [`docs.clndr.pro/openapi.json`](/openapi.json) | | SDK source | [github.com/devsForFun/clndr-pro-sdk](https://github.com/devsForFun/clndr-pro-sdk) | | Help | [hello@devsforfun.com](mailto:hello@devsforfun.com) | --- # Quickstart By the end of this page a guest can open `/book` on your Next.js site, pick a slot, and book a meeting that shows up under **Meetings** in your clndr.pro dashboard. You need a clndr.pro account and a Next.js 14+ app using the App Router. In the dashboard, open [**Availability**](https://www.clndr.pro/availability) and set the hours you take meetings. Slots only exist inside these hours, so a page with no availability shows no times at all. Connect Google Calendar when the dashboard asks. clndr.pro then keeps your existing events off the slot list and can add Meet links to bookings. It works without Google too: bookings still land in **Meetings**. Open [**Booking pages**](https://www.clndr.pro/booking), choose **New booking page**, and note its **Link**, the slug at the end of its URL. A page at `www.clndr.pro/ada/intro-call` has the slug `intro-call`. Leave **Confirmation** on *Instant* for now, so test bookings confirm immediately. *Needs approval* is covered in [How booking works](/concepts#booking-types). Open [**API keys**](https://www.clndr.pro/api-keys), choose **Create API Key**, pick **Publishable key** and give it a name like "Marketing site". Copy the key: it starts with `clndr_pk_` and is shown once. A publishable key is meant to sit in browser code. It can read your booking pages and slots and create bookings, nothing else. [API keys](/api-keys) explains the two key types. ```bash npm install @clndr-pro/react ``` Then put the key in `.env.local`: ```bash title=".env.local" NEXT_PUBLIC_CLNDR_PUBLISHABLE_KEY=clndr_pk_... ``` Use `@clndr-pro/react` 0.2.1 or later. Earlier versions call `https://clndr.pro`, which redirects to `www` and loses the key on the way, so every request fails with `401 Missing API key`. If you're stuck on an older version, pass `baseUrl="https://www.clndr.pro"` to `ClndrProvider`. `ClndrProvider` holds the API client every component and hook uses. It's a client component, so give it its own file instead of turning your root layout into one: ```tsx title="app/providers.tsx" 'use client'; import { ClndrProvider } from '@clndr-pro/react'; export function Providers({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ```tsx title="app/layout.tsx" {1,7} import { Providers } from './providers'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ```tsx title="app/book/page.tsx" import { BookingWidget } from './booking-widget'; export const metadata = { title: 'Book a call' }; export default function BookPage() { return (
); } ``` ```tsx title="app/book/booking-widget.tsx" 'use client'; import { BookingInline } from '@clndr-pro/react'; export function BookingWidget() { return ( console.log('booked', bookingId)} /> ); } ``` Replace `intro-call` with your slug. The page itself stays a Server Component; only the widget runs in the browser.
Run `npm run dev`, open [localhost:3000/book](http://localhost:3000/book), pick a date and a time, and confirm with your own email address. You'll get the confirmation email, and the meeting appears under [**Meetings**](https://www.clndr.pro/meetings).
## Make it look like your site The form ships with plain, unstyled markup. Hand it your own `Button`, `Input` and `Label` and it renders with them: ```tsx title="app/book/booking-widget.tsx" 'use client'; import { BookingInline } from '@clndr-pro/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; export function BookingWidget() { return ( ); } ``` [Styling and slots](/react/styling) lists every slot and class name. To design the whole flow yourself (a calendar, grouped time slots, your own form), use the hooks instead: the [shadcn booking widget](/tutorials/shadcn-booking-widget) tutorial builds one. ## Next - Keep the guest's details off the browser-to-clndr.pro path with [a server action](/tutorials/server-actions). - Approve or decline requests from your own admin screen: [Approve bookings in your admin](/tutorials/approvals-dashboard). - Something not working? [Troubleshooting](/troubleshooting) covers the usual suspects: `401`, empty slot lists, and dates a day off. --- # Choose an integration There are five ways to put a clndr.pro booking page on your site. They trade the same two things against each other: how much of the booking UI you own, and how much code you write. The first two show the hosted UI, the one guests see at `www.clndr.pro/{username}/{slug}`. The other three render inside your own app. | | Embed script | Iframe | React components | React hooks | Server SDK / REST | | --- | --- | --- | --- | --- | --- | | Guest sees | clndr.pro's booking UI, inline, in a modal or behind a floating button | clndr.pro's booking UI in a frame | A booking form rendered by your app | Whatever you build | Whatever you build | | Styling | `theme`, `primaryColor`, hide the header | Same as the script, via query params | Your own `Button`, `Input`, `Label`… plus class names on every element | All of it | All of it | | Key | None | None | Publishable | Publishable | Secret (server) | | You hear about bookings from | `onBookingSuccessful` | `postMessage` events | `onBooked(bookingId)` | The return value of `create()` | Your own handler; [polling](/tutorials/sync-bookings) for everything else | | Private pages | Pass the access code | Pass the access code (`?c=`) | Works with the owner's key | Works with the owner's key | Works with the owner's key | | Needs | One ` ``` Call `Clndr` from the script's `onload`, as above. A plain ` ``` The guest closes it with the × button, the Escape key or a click outside. After a successful booking it closes by itself 2.5 seconds later, once the guest has seen the confirmation. ## Floating button A fixed button in a corner of every page, which opens the modal: ```html title="footer code" ``` Put it in your site-wide footer code so it shows on every page. ## Options All three functions take `link` and an optional `config`. `inline` only. A CSS selector or a DOM element to append the iframe to. Throws if nothing matches. `username/slug` of the booking page. Throws if it isn't two segments. `floatingButton` only. `floatingButton` only. The button sits 24px from the two edges. `auto` follows the guest's system setting. Hides the host's name, the page title, the duration and the description, for when your page already shows them. Any CSS colour, used for buttons and highlights: `'#9A5A16'`, `'rgb(74 78 143)'`. Fills in the guest's name and email, for signed-in visitors. Required for `private` pages: the embed has no code prompt of its own, and without the code it shows "Booking page not found". Called once a booking is made. See [Events](#events). Called when the booking page loads, with `{ path: '/embed/ada/intro-call' }`. Each function returns a handle. `inline` returns `{ iframe, destroy }`, `modal` returns `{ destroy }`, and `floatingButton` returns `{ button, destroy }`. `destroy()` removes what was added and stops listening for its events. On a floating button it removes the button but leaves an open modal alone. ## Events ```js Clndr.inline({ elementOrSelector: '#booking', link: 'ada/intro-call', config: { onBookingSuccessful({ meetingId, startTime, endTime }) { // meetingId is the booking's id; the times are UTC ISO strings. window.dataLayer?.push({ event: 'booking_made', meetingId }); window.location.href = '/thanks?at=' + encodeURIComponent(startTime); }, }, }); ``` `onBookingSuccessful` fires for approval pages too, when the request is sent. To act on the host's decision, use a secret key on your server: [Sync bookings to your database](/tutorials/sync-bookings) shows how. Under the hood these are `postMessage` events from the iframe. The script handles the resizing for you; [Iframe embed](/embed/iframe#events) lists the raw messages. ## In Next.js or React If you'd rather render the booking form with your own components, use [`@clndr-pro/react`](/react/components) instead. To keep clndr.pro's own UI, load the script with `next/script`: ```tsx title="components/clndr-embed.tsx" 'use client'; import Script from 'next/script'; import { useEffect, useRef } from 'react'; type Handle = { destroy(): void }; declare global { interface Window { Clndr?: { inline(options: { elementOrSelector: Element | string; link: string; config?: Record }): Handle; }; } } export function ClndrEmbed({ link }: { link: string }) { const container = useRef(null); const handle = useRef(null); function mount() { if (!container.current || !window.Clndr || handle.current) return; handle.current = window.Clndr.inline({ elementOrSelector: container.current, link, config: { theme: 'auto' }, }); } useEffect(() => { mount(); // the script may already be loaded from a previous page return () => { handle.current?.destroy(); handle.current = null; }; }, [link]); return ( <> ``` Check `event.origin` on every message. The embed posts to `*`, so any page that frames it receives the messages, and you should only trust the ones that come from clndr.pro. ## Events The embed posts `{ type, payload }` messages to its parent window: | `type` | `payload` | Sent when | | --- | --- | --- | | `__clndr:resize` | `{ height: number }` | On load, and whenever the content's height changes | | `__clndr:route_changed` | `{ path: string }` | When the booking page loads, for example `/embed/ada/intro-call` | | `__clndr:booking_successful` | `{ meetingId?: string, startTime: string, endTime: string }` | After the guest books; times are UTC ISO strings | ```js window.addEventListener('message', (event) => { if (event.origin !== 'https://www.clndr.pro') return; if (event.data?.type === '__clndr:booking_successful') { const { meetingId, startTime } = event.data.payload; console.log('Booked', meetingId, 'for', new Date(startTime).toLocaleString()); } }); ``` On approval pages `booking_successful` means the request was sent; the booking is `pending` until the host confirms it. ## Private and team pages A `private` page needs its access code in `c`. Anyone who can read your page's HTML can read that code too, so treat an embedded access code as shared with your site's visitors. `team` pages don't work inside an iframe. They need the guest to be signed in to clndr.pro, and browsers don't send clndr.pro's cookies to a frame on another site. Link to the hosted page instead. ## Quirks worth knowing - A `select` question shows as a plain text input in the embed, a `phone` question as a text input, and a `checkbox` question as a checkbox whose answer is always saved empty. If a page uses `select` or `checkbox` questions, render the form yourself with [`@clndr-pro/react`](/react/components). - The embed asks for slots by the guest's local midnight, and clndr.pro answers with the host's day that contains that moment. When the guest and the host are many hours apart, the times under a date can fall on the day before or after it in the guest's time. The [API](/api-reference/operations/listSlots) takes a host-timezone date and leaves out past slots, so custom integrations don't have this problem. --- # Next.js App Router The [quickstart](/quickstart) puts one booking form on one page. This guide is the setup you keep: both keys in the right places, a URL for each of your booking pages with real titles in search results, and a server route for the bookings that come in. It assumes Next.js 15 or 16 with the App Router; the [Pages Router](#pages-router) section at the end covers the older setup. ## Two keys, two sides of the boundary | Key | Env var | Used by | Can | | --- | --- | --- | --- | | Publishable `clndr_pk_…` | `NEXT_PUBLIC_CLNDR_PUBLISHABLE_KEY` | `@clndr-pro/react` in the browser | Read pages and slots, create bookings | | Secret `clndr_sk_…` | `CLNDR_SECRET_KEY` | `@clndr-pro/sdk` in Server Components, route handlers, server actions | Everything above, plus list, confirm and cancel bookings | ```bash npm install @clndr-pro/react @clndr-pro/sdk server-only ``` `@clndr-pro/react` already depends on the SDK. Installing `@clndr-pro/sdk` directly lets server code import it without pulling in React components, and `server-only` turns an accidental client import of your secret key into a build error. ```bash title=".env.local" NEXT_PUBLIC_CLNDR_PUBLISHABLE_KEY=clndr_pk_... CLNDR_SECRET_KEY=clndr_sk_... ``` On Vercel, add both under **Settings → Environment Variables** (or run `vercel env add CLNDR_SECRET_KEY`). `NEXT_PUBLIC_` values are inlined into the JavaScript bundle at build time, so redeploy after changing the publishable key. Never give the secret key a `NEXT_PUBLIC_` prefix. Use `@clndr-pro/react` 0.2.1+ and `@clndr-pro/sdk` 0.1.5+. Older versions send requests to `https://clndr.pro`, which redirects to `www` and drops the key, so every call fails with `401 Missing API key`. If you can't upgrade, pass `baseUrl="https://www.clndr.pro"` to `ClndrProvider` and `baseUrl: 'https://www.clndr.pro'` to `new Clndr()`. ## The browser side `ClndrProvider` creates the browser client from the publishable key. Give it its own client file and wrap your layout's children, so the layout stays a Server Component: ```tsx title="app/providers.tsx" 'use client'; import { ClndrProvider } from '@clndr-pro/react'; export function Providers({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ```tsx title="app/layout.tsx" import { Providers } from './providers'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` If only one section of the site books meetings, put `` in that section's layout (`app/book/layout.tsx`) instead of the root. Everything from `@clndr-pro/react` (components, hooks, `ClndrProvider`) runs in the browser. Import it from files that start with `'use client'`. From 0.2.1 the package marks itself as client code, so importing `BookingInline` straight into a Server Component also works, but a small client wrapper works on every version and is the only place you can pass function props like `onBooked`. ## The server side One module owns the secret key: ```ts title="lib/clndr.ts" import 'server-only'; import { Clndr } from '@clndr-pro/sdk'; if (!process.env.CLNDR_SECRET_KEY) { throw new Error('Set CLNDR_SECRET_KEY (a clndr_sk_ key) in .env.local'); } /** Server-only client. The secret key never reaches the browser bundle. */ export const clndr = new Clndr(process.env.CLNDR_SECRET_KEY); ``` Import `clndr` from Server Components, route handlers and server actions. If a client component ever imports `lib/clndr.ts`, even indirectly, `server-only` fails the build. ## A page for every booking page `app/book/[slug]/page.tsx` serves `/book/intro-call`, `/book/demo` and so on. The server reads the booking page with the secret key to set the `` and meta description, so search engines and link previews see "Intro call with Ada Lovelace" instead of a loading spinner. The form itself is the client widget, which fetches slots live. ```tsx title="app/book/[slug]/page.tsx" import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { cache } from 'react'; import { ClndrError } from '@clndr-pro/sdk'; import { clndr } from '@/lib/clndr'; import { BookingWidget } from './booking-widget'; // Re-render each booking page at most every 5 minutes. Slots aren't part of // this HTML (the widget fetches them live), so only the title and // description can be up to 5 minutes stale. export const revalidate = 300; // One API call per request, shared by generateMetadata and the page. const getBookingPage = cache(async (slug: string) => { try { return await clndr.bookingPages.get(slug); } catch (err) { if (err instanceof ClndrError && err.status === 404) return null; throw err; } }); export async function generateStaticParams() { const pages = await clndr.bookingPages.list(); return pages.filter((p) => p.is_active && p.visibility === 'public').map((p) => ({ slug: p.slug })); } function summary(markdown: string | null, fallback: string) { const text = markdown?.replace(/[#*_`>[\]()]/g, '').replace(/\s+/g, ' ').trim(); return text ? text.slice(0, 160) : fallback; } export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { const { slug } = await params; const page = await getBookingPage(slug); if (!page) return {}; const { bookingPage, userProfile } = page; const host = userProfile.full_name ?? userProfile.username; return { title: `${bookingPage.title} with ${host}`, description: summary(bookingPage.description, `Book ${bookingPage.duration_minutes} minutes with ${host}.`), }; } export default async function BookingPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const page = await getBookingPage(slug); // Your key can read your private and team pages too. Only show public ones. if (!page || page.bookingPage.visibility !== 'public') notFound(); return ( <main className="mx-auto max-w-2xl px-4 py-12"> <BookingWidget slug={slug} /> </main> ); } ``` ```tsx title="app/book/[slug]/booking-widget.tsx" 'use client'; import { BookingInline } from '@clndr-pro/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; export function BookingWidget({ slug }: { slug: string }) { return ( <BookingInline slug={slug} components={{ Button, Input, Label, Textarea }} classNames={{ root: 'space-y-6', heading: 'text-2xl font-semibold tracking-tight', muted: 'text-sm text-muted-foreground', label: 'mb-2 block', dateInput: 'w-full', slotGrid: 'mt-4 grid grid-cols-3 gap-2', slotButton: 'bg-background text-foreground border hover:bg-accent hover:text-accent-foreground', card: 'space-y-4 rounded-xl border p-6', buttonSecondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', error: 'text-sm text-destructive', }} /> ); } ``` Three things this page relies on: - **The visibility check is yours to make.** The API hands your key every page on your account, including `private` pages (which need an access code on clndr.pro) and `team` pages. The hosted page enforces those rules. Your site doesn't, unless you check `visibility` as above. - **`generateStaticParams` runs at build time**, so `CLNDR_SECRET_KEY` has to exist in the build environment. Pages you create later still work: `dynamicParams` defaults to `true`, so a new slug renders on its first request and is cached from then on. - **`revalidate` keeps the API calls down.** A secret key gets 300 requests a minute, and a statically generated page calls the API once per revalidation, not once per visitor. <Note> With `cacheComponents` turned on (Next.js 16), route segment options like `revalidate` aren't available. Move the API call into an async function marked `'use cache'` that calls `cacheLife('minutes')`, and call it from the page and `generateMetadata` in place of `cache()`. </Note> ## A route handler for your admin Anything that reads bookings needs the secret key and returns guest names and emails, so it lives on the server behind your own auth check: ```ts title="app/api/bookings/route.ts" import { NextResponse } from 'next/server'; import { clndr } from '@/lib/clndr'; import { auth } from '@/auth'; // your auth library const STATUSES = ['pending', 'confirmed', 'cancelled'] as const; type Status = (typeof STATUSES)[number]; /** GET /api/bookings?status=pending — for your own admin screens only. */ export async function GET(request: Request) { // This returns guest names and emails. Never ship it without an auth check. const session = await auth(); if (session?.user?.role !== 'admin') { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const param = new URL(request.url).searchParams.get('status'); const status = STATUSES.includes(param as Status) ? (param as Status) : undefined; const bookings = await clndr.bookings.list({ status, limit: 100 }); return NextResponse.json({ bookings }); } ``` For confirming and declining from a screen, server actions are less code than a route handler: [Approve bookings in your admin](/tutorials/approvals-dashboard) builds one. To keep guest details off the browser-to-clndr.pro path entirely, book through [a server action](/tutorials/server-actions) instead of `BookingInline`. ## What you can skip You don't need middleware or `proxy.ts` changes: the API accepts cross-origin requests from any site. You don't need a `next.config` entry either, since the packages ship compiled JavaScript. If your site sends a Content-Security-Policy, the browser has to reach the API: add `https://www.clndr.pro` to `connect-src`. If you also use the [iframe](/embed/iframe) or [script](/embed/script) embed, add it to `frame-src`, and to `script-src` for the script. ## Pages Router The same pieces, with `_app.tsx` holding the provider and `getStaticProps` doing the server work. There's no `'use client'` to manage: page components render in the browser after hydration, and the data functions never ship to it. ```tsx title="pages/_app.tsx" import type { AppProps } from 'next/app'; import { ClndrProvider } from '@clndr-pro/react'; export default function App({ Component, pageProps }: AppProps) { return ( <ClndrProvider publishableKey={process.env.NEXT_PUBLIC_CLNDR_PUBLISHABLE_KEY!}> <Component {...pageProps} /> </ClndrProvider> ); } ``` ```tsx title="pages/book/[slug].tsx" import type { GetStaticPaths, GetStaticProps } from 'next'; import Head from 'next/head'; import { BookingInline } from '@clndr-pro/react'; import { Clndr, ClndrError } from '@clndr-pro/sdk'; type Props = { slug: string; title: string }; // getStaticProps and getStaticPaths only run on the server, so the secret key stays there. const clndr = new Clndr(process.env.CLNDR_SECRET_KEY!); export const getStaticPaths: GetStaticPaths = async () => { const pages = await clndr.bookingPages.list(); return { paths: pages.filter((p) => p.is_active && p.visibility === 'public').map((p) => ({ params: { slug: p.slug } })), fallback: 'blocking', }; }; export const getStaticProps: GetStaticProps<Props, { slug: string }> = async ({ params }) => { try { const { bookingPage } = await clndr.bookingPages.get(params!.slug); if (bookingPage.visibility !== 'public') return { notFound: true }; return { props: { slug: bookingPage.slug, title: bookingPage.title }, revalidate: 300 }; } catch (err) { if (err instanceof ClndrError && err.status === 404) return { notFound: true }; throw err; } }; export default function BookPage({ slug, title }: Props) { return ( <> <Head> <title>{title}
); } ``` ```text Set up clndr.pro booking in this Next.js app, following https://docs.clndr.pro/nextjs.md (read it first). - Install @clndr-pro/react (0.2.1+), @clndr-pro/sdk (0.1.5+) and server-only. - Add NEXT_PUBLIC_CLNDR_PUBLISHABLE_KEY and CLNDR_SECRET_KEY to .env.example with placeholders. Don't touch .env.local; tell me to fill it in. - Create app/providers.tsx ('use client', ClndrProvider with the publishable key) and wrap the root layout's children with it. Keep the layout a Server Component. - Create lib/clndr.ts with import 'server-only' and a Clndr client built from CLNDR_SECRET_KEY. - Create app/book/[slug]/page.tsx: generateStaticParams from clndr.bookingPages.list() (active, public pages only), generateMetadata from clndr.bookingPages.get(slug), notFound() for a 404 ClndrError or a non-public page, revalidate = 300. Render a 'use client' BookingWidget that uses BookingInline with this project's own Button/Input/Label/Textarea components if it has them (components/ui). - If the site sets a Content-Security-Policy, add https://www.clndr.pro to connect-src. - Run the type checker and the build and fix what breaks. List the files you changed. ``` --- # Components `@clndr-pro/react` has one provider and three components. The components are the same booking flow in different wrappers, so most of this page is about one of them. ```bash npm install @clndr-pro/react ``` Everything here runs in the browser: import it from a file with `'use client'` at the top. The [Next.js guide](/nextjs) shows where those files go. ## ClndrProvider Creates the API client the components and hooks use, and passes it down through React context. ```tsx title="app/providers.tsx" 'use client'; import { ClndrProvider } from '@clndr-pro/react'; export function Providers({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` | Prop | Type | | | --- | --- | --- | | `publishableKey` | `string` | A `clndr_pk_` key. Required unless you pass `client`. | | `client` | `Clndr` | An already-built client. Takes priority over `publishableKey`. | | `baseUrl` | `string` | API host. Defaults to `https://www.clndr.pro` from 0.2.1. Only needed on older versions, or against a local clndr.pro. | | `children` | `ReactNode` | | The provider throws if it gets neither `publishableKey` nor `client`, and every component or hook throws `useClndr must be used inside ` when there's no provider above it. ### Your own client Pass `client` when you need control over how requests are made: a custom `fetch` for logging or tests, or a different base URL. Build it once, outside the component. The provider memoises on the client's identity, so a new `Clndr` on every render recreates the context each time. ```tsx title="components/debug-booking.tsx" 'use client'; import { Clndr, ClndrProvider, BookingInline } from '@clndr-pro/react'; // Built once at module scope: ClndrProvider memoises on the client's identity. const client = new Clndr({ apiKey: process.env.NEXT_PUBLIC_CLNDR_PUBLISHABLE_KEY!, fetch: (input, init) => { console.debug('clndr request', input); return fetch(input, init); }, }); export function DebugBooking() { return ( ); } ``` ### Several accounts on one site A key belongs to one clndr.pro account, and a slug is only unique within an account. To book with two people who each have their own account, give each section its own provider. Components use the nearest one. ```tsx ``` ## BookingInline and BookingForm `BookingInline` renders `BookingForm` and nothing else; use whichever name reads better in your code. Both take the same props. ```tsx title="app/book/booking-widget.tsx" 'use client'; import { BookingInline } from '@clndr-pro/react'; export function BookingWidget() { return console.log('booked', id)} />; } ``` | Prop | Type | | | --- | --- | --- | | `slug` | `string` | The booking page's slug. Required. | | `prefill` | `{ name?: string; email?: string }` | Initial values for the guest's name and email. The guest can still edit them. | | `onBooked` | `(bookingId: string) => void` | Called after the booking is created, with its `id`. | | `components` | `ComponentSlots` | Your own `Button`, `Input`, `Label` and other elements. See [Styling and slots](/react/styling). | | `classNames` | `ClassNameMap` | Class names for each part. See [Styling and slots](/react/styling). | ### What the guest sees 1. While the page loads: "Loading…". If the slug doesn't exist on the key's account, or the page is inactive, the API's error message ("Booking page not found"). 2. The page title, then the host's name and the duration ("Ada Lovelace · 30 min"), then the page description rendered as Markdown. 3. A date input from today on, capped at `max_days_ahead` days from now when the page sets a limit. 4. After a date is picked, the open times for that day as buttons, in the guest's local time. "No available slots for this date." when there are none. 5. After a time is picked, a form: name, email, and the host's questions in their order. Required questions are marked with `*` and use the browser's `required` validation. 6. **Confirm** creates the booking. If that fails (someone else took the time, say), the API's message appears above the buttons. **Back** returns to the times. 7. A success card. *Direct* pages say "Booking confirmed" and that a confirmation email is coming. *Approval* pages say the request was submitted and the guest will hear once it's approved (the heading reads "Request sent" from 0.2.1; 0.2.0 says "Booking confirmed" for both). Question types map to inputs like this from 0.2.1: `textarea` gets a text area, `email` an email input, `phone` a `tel` input, `select` a ``, split the string: `new Date('2026-10-15')` is UTC midnight, which is still the 14th for anyone west of UTC. ```ts /** `YYYY-MM-DD` from → a Date at local midnight. */ function parseLocalDate(value: string) { const [y, m, d] = value.split('-').map(Number); return new Date(y, m - 1, d); } ``` Upgrading from `@clndr-pro/react` 0.2.0? Its `useAvailableSlots` had no `refetch`, skipped a request that matched the previous slug and day, and could sit on `loading` forever under React Strict Mode (every `next dev`) when a component mounted with a date already set. It also sent a `Date` as a full UTC timestamp, which the API reads as "the host's day containing this instant", one day off for many timezone pairs. 0.2.1 (with SDK 0.1.5) fixes all of that. ## useCreateBooking ```ts function useCreateBooking(): { create: (input: CreateBookingInput) => Promise; data: Booking | null; status: 'idle' | 'loading' | 'success' | 'error'; error: ClndrError | Error | null; isLoading: boolean; }; interface CreateBookingInput { bookingPageId: string; // page.bookingPage.id guestName: string; guestEmail: string; startTime: string; // a slot's `start`, unchanged endTime: string; // the same slot's `end` responses?: Array<{ questionId: string; answer: string }>; } ``` `create` both updates the hook's state and returns (or throws) the result, so you can `await` it in a submit handler. Catch the rejection: the error is on `error` too, and an uncaught rejection in an event handler only ends up in the console (`` catches its own). ```tsx const { create, data: booking, error, isLoading } = useCreateBooking(); async function onSubmit() { try { await create({ bookingPageId: page.bookingPage.id, guestName: name, guestEmail: email, startTime: slot.start, endTime: slot.end, responses: [{ questionId: questions[0].id, answer: topic }], }); } catch { // shown from `error` } } if (booking) { return

{booking.status === 'pending' ? 'Request sent' : "You're booked"}

; } ``` The returned `Booking` is `confirmed` on *direct* pages and `pending` on *approval* pages; on direct pages with Meet links on it already carries `google_meet_link`. It doesn't include the answers. Send each answer once, only for questions on this page. One unknown `questionId` and none of the answers are saved (the booking still is). Skip questions the guest left blank, and check required ones before you call `create`: the API doesn't. ## useClndr ```ts function useClndr(): Clndr; ``` The provider's client, for calls the other hooks don't cover. With a publishable key that's `bookingPages.list()`, `bookingPages.get()`, `bookingPages.getSlots()` and `bookings.create()`; the [TypeScript SDK](/sdk) page documents each. A page picker, for example: ```tsx title="components/booking-page-list.tsx" 'use client'; import { useEffect, useState } from 'react'; import { useClndr, type BookingPage } from '@clndr-pro/react'; export function BookingPageList({ onPick }: { onPick: (slug: string) => void }) { const clndr = useClndr(); const [pages, setPages] = useState(null); useEffect(() => { clndr.bookingPages .list() .then((all) => setPages(all.filter((p) => p.is_active && p.visibility === 'public'))) .catch(() => setPages([])); }, [clndr]); if (!pages) return

Loading…

; return (
    {pages.map((p) => (
  • ))}
); } ``` `list()` returns every page on the account, inactive and private ones included, so filter before showing it to guests. ## A minimal custom flow All three hooks together, unstyled, in one component: ```tsx title="components/minimal-booking.tsx" 'use client'; import { useState, type FormEvent } from 'react'; import { useAvailableSlots, useBookingPage, useCreateBooking, type TimeSlot } from '@clndr-pro/react'; /** `YYYY-MM-DD` from → a Date at local midnight. */ function parseLocalDate(value: string) { const [y, m, d] = value.split('-').map(Number); return new Date(y, m - 1, d); } export function MinimalBooking({ slug }: { slug: string }) { const { data: page, error: pageError } = useBookingPage(slug); const [day, setDay] = useState(''); const date = day ? parseLocalDate(day) : null; const { slots, isLoading: loadingSlots } = useAvailableSlots(slug, date); const [slot, setSlot] = useState(null); const { create, data: booking, error, isLoading } = useCreateBooking(); if (pageError) return

{pageError.message}

; if (!page) return

Loading…

; if (booking) return

{booking.status === 'pending' ? 'Request sent.' : 'Booked!'} Check your inbox.

; async function book(e: FormEvent) { e.preventDefault(); if (!slot || !page) return; const form = new FormData(e.currentTarget); await create({ bookingPageId: page.bookingPage.id, guestName: String(form.get('name')), guestEmail: String(form.get('email')), startTime: slot.start, endTime: slot.end, }).catch(() => {}); // the hook's `error` has the message } return (

{page.bookingPage.title}

{ setDay(e.target.value); setSlot(null); }} /> {loadingSlots ?

Loading times…

: null}
{slots.map((s) => ( ))}
{slot ? (
{error ?

{error.message}

: null}
) : null}
); } ``` It skips the host's questions and doesn't refetch a taken slot; the [shadcn booking widget](/tutorials/shadcn-booking-widget) tutorial handles both and adds a calendar. ## A slot someone else just took Two guests can pick the same time. The second `create` fails with a `ClndrError` with status `409`. With a publishable key (the usual case in the browser) the message is usually "This time slot is not available. Fetch the slots again and pick another.", because the API checks the slot is still open before booking; "This time slot is no longer available. Please pick another." means both requests passed that check at the same moment. Every other failure is a `ClndrError` too, with the API's message: ```ts import { ClndrError } from '@clndr-pro/react'; function isSlotTaken(err: unknown) { return err instanceof ClndrError && (err.status === 409 || /no longer available|not available/i.test(err.message)); } ``` | `err.status` | Likely cause | | --- | --- | | `400` | A missing field, a bad email address, or a timestamp that doesn't parse | | `409` | Slot taken, or not an open slot (message above) | | `401` | Missing or revoked key, or requests going to `https://clndr.pro` instead of `www` | | `403` | The page isn't on this key's account | | `429` | The key's rate limit, or the guest's email used its 5 booking attempts this clock hour; `err.data` has the message, the `Retry-After` header isn't exposed | After a taken slot, clear the picked slot and ask for a fresh list with `refetch()`: ```tsx const { slots, refetch } = useAvailableSlots(slug, date); // in the submit handler's catch: if (isSlotTaken(err)) { setSlot(null); refetch(); } ``` `ClndrError` has `message`, `status` (the HTTP status) and `data` (the parsed response body, `{ error: string }`). [Errors and rate limits](/errors) lists every message the API sends. --- # Styling and slots `BookingForm`, `BookingInline` and `BookingModal` ship plain HTML with a `clndr-*` class on every part and no CSS at all. Two props change that: - `components` swaps the elements themselves: your `Button`, `Input`, `Label`. - `classNames` swaps the class on each part. Use both with a component library, `classNames` alone with Tailwind, or neither and write CSS against the default classes. ## Slots: the components prop | Slot | Default | Renders | | --- | --- | --- | | `Root` | `
` | The outer wrapper, in every state | | `Card` | `
` | The details form, and the success message | | `Heading` | `

` | The page title, and the success heading | | `Muted` | `
` | Host and duration, the picked time, loading and empty messages | | `Error` | `
` | Load and booking errors | | `Button` | `