# 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 (
<>
>
);
}
```
`onReady` runs once the script loads and again on every remount, and `destroy()` on unmount stops a client-side navigation from leaving a second frame behind.
## WordPress, Webflow and other builders
- **WordPress**: add a **Custom HTML** block where the booking page should go and paste the inline snippet. For a floating button on every page, paste that snippet into your theme's footer or a header-and-footer plugin.
- **Webflow**: drop a **Code Embed** element on the page for the inline snippet. For the floating button, use **Site settings → Custom code → Footer code**.
- **Anything else**: if it accepts an HTML block with scripts, the snippets work unchanged. If it strips scripts, use the [iframe](/embed/iframe).
## Content Security Policy
If your site sends a CSP header, allow the script and the frame:
```text title="Content-Security-Policy"
script-src 'self' https://www.clndr.pro;
frame-src https://www.clndr.pro;
```
The snippets above use an inline `onload` attribute. Under a strict CSP that blocks inline handlers, move `mountBooking` into a script file of your own and attach the listener there: `document.querySelector('script[src$="/embed.js"]').addEventListener('load', mountBooking)`.
## Limits
- The embed shows clndr.pro's UI: you can change the theme and the accent colour, not the layout or the fonts.
- `team` pages don't load inside the frame, because browsers don't send clndr.pro's sign-in cookie to a third-party iframe.
---
# Iframe embed
Every booking page has an embeddable version at:
```text
https://www.clndr.pro/embed/{username}/{slug}
```
It's the same booking flow as the hosted page, without clndr.pro's header and footer, and it allows framing from any site. This is what the [embed script](/embed/script) creates for you. Use the iframe directly when you can't run scripts, or when you want full control of the frame.
```html title="index.html"
```
## Query parameters
`auto` follows the guest's system setting.
Hides the host's name, the page title, the duration and the description.
Any CSS colour for buttons and highlights. URL-encode it: `#9A5A16` becomes `%239A5A16`.
Prefills the guest's name.
Prefills the guest's email.
An access code, for `private` pages. Without it a private page shows "Booking page not found".
Build the URL with `URLSearchParams` so names and colours are encoded properly:
```js
const src = new URL('https://www.clndr.pro/embed/ada/intro-call');
src.search = new URLSearchParams({ theme: 'dark', primaryColor: '#E0913D', email: 'grace@example.com' }).toString();
```
## Sizing
The page's height changes as the guest moves from the date picker to the slots to the form. The frame can't know that by itself, so either give it a fixed height (640px fits the date and slot steps on most screens) or listen for the page's resize messages:
```html title="index.html"
```
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 {
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 (
);
}
```
```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 (
);
}
```
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.
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()`.
## 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 (
);
}
```
```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 = 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 (
<>
{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 `