# Next.js App Router

Set up clndr.pro in a Next.js App Router app — both keys, the client/server split, a statically generated page per booking page, and a protected route handler for your admin.

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 (
    <ClndrProvider publishableKey={process.env.NEXT_PUBLIC_CLNDR_PUBLISHABLE_KEY!}>
      {children}
    </ClndrProvider>
  );
}
```

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

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

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

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 (
    <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}</title>
      </Head>
      <main style={{ maxWidth: 640, margin: '48px auto', padding: 16 }}>
        <BookingInline slug={slug} />
      </main>
    </>
  );
}
```

**Set up clndr.pro in this Next.js app**

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