Next.js App Router
The 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 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 |
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.
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:
'use client';
import { ClndrProvider } from '@clndr-pro/react';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ClndrProvider publishableKey={process.env.NEXT_PUBLIC_CLNDR_PUBLISHABLE_KEY!}>
{children}
</ClndrProvider>
);
}import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}If only one section of the site books meetings, put <Providers> 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:
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 <title> 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.
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>
);
}'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
privatepages (which need an access code on clndr.pro) andteampages. The hosted page enforces those rules. Your site doesn't, unless you checkvisibilityas above. generateStaticParamsruns at build time, soCLNDR_SECRET_KEYhas to exist in the build environment. Pages you create later still work:dynamicParamsdefaults totrue, so a new slug renders on its first request and is cached from then on.revalidatekeeps 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:
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 builds one. To keep guest details off the browser-to-clndr.pro path entirely, book through a server action 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 or 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.
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>
);
}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}</title>
</Head>
<main style={{ maxWidth: 640, margin: '48px auto', padding: 16 }}>
<BookingInline slug={slug} />
</main>
</>
);
}Set up clndr.pro in this Next.js app
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.