nisebookings docs

Next.js

Two ways in, both fine: the widget (fastest, no booking code of your own) or your own UI against the public API. This page is the Next.js-specific part — the behaviour of the widget itself is the widget reference, the API is the public API.

Everything below is one runnable app in examples/nextjs/ (App Router, tested with Next 16 / React 19). Both pages point at https://app.nisebookings.com.

1. A key

The public key goes in the browser — it is public by design (it reads the catalogue and creates bookings, nothing else). Copy it from Instellingen → Insluiten, or get a 24-hour test account:

curl -s -X POST https://app.nisebookings.com/api/sandbox    # → { publicKey, apiKey, … }
# .env.local
NEXT_PUBLIC_NISE_KEY=<the public key>

Never put a management key (nk_…) in a NEXT_PUBLIC_* variable or in client code: that key can change your whole account.

2. The widget, in one client component

app/layout.tsx — nothing special, but this is where lang and the stylesheet live:

import type { ReactNode } from 'react';
import './globals.css';

export const metadata = { title: 'Salon Vlek' };

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="nl">
      <body>
        {children}
        {/* React hoists and dedupes this; the element below upgrades itself when the script runs. */}
        <script async src="https://app.nisebookings.com/widget.js" />
      </body>
    </html>
  );
}

app/booking-widget.tsx:

'use client';
import type { DetailedHTMLProps, HTMLAttributes } from 'react';

// React renders an unknown tag as-is, but TypeScript needs the attribute types once.
declare module 'react' {
  namespace JSX {
    interface IntrinsicElements {
      'nise-booking': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & { 'data-key': string };
    }
  }
}

/** The widget path: the script lives in app/layout.tsx; this is just the element it upgrades. */
export function BookingWidget() {
  const key = process.env.NEXT_PUBLIC_NISE_KEY;
  if (!key) return <p>Set NEXT_PUBLIC_NISE_KEY in .env.local (see 08-sandbox.md for a test key).</p>;
  return <nise-booking data-key={key} />;
}

Use it from any page — a server component is fine:

import Link from 'next/link';
import { BookingWidget } from './booking-widget';

export default function Home() {
  return (
    <main>
      <h1>Salon Vlek</h1>
      <p>Knippen, kleuren en groepslessen. Boek direct online:</p>
      <BookingWidget />
      <p><Link href="/headless">Zelfde afspraak, maar met eigen UI →</Link></p>
    </main>
  );
}

Why it is this small:

3. Your own UI on the public API

Two components: the server fetches the catalogue, the client fetches times and books.

app/headless/page.tsx:

import { BookingForm } from './booking-form';

export type Service = { id: string; title: string; duration: number; price: number };

/** Availability changes by the minute, so this page is not prerendered. */
export const dynamic = 'force-dynamic';

async function listServices(): Promise<Service[]> {
  const res = await fetch(`https://app.nisebookings.com/api/public/${process.env.NEXT_PUBLIC_NISE_KEY}/services`, { cache: 'no-store' });
  if (!res.ok) throw new Error(`services answered ${res.status}`);
  const { services } = await res.json();
  return services.map(({ id, title, duration, price }: Service) => ({ id, title, duration, price }));
}

export default async function Headless() {
  const services = await listServices();
  return (
    <main>
      <h1>Boek online</h1>
      <p>Deze pagina haalt de diensten server-side op en boekt via de publieke API — geen widget.</p>
      <BookingForm services={services} />
    </main>
  );
}

app/headless/booking-form.tsx:

'use client';
import { useState } from 'react';
import type { Service } from './page';

type Slot = { start: string; time: string; staffId: string };

const API = 'https://app.nisebookings.com';

/** Own UI on the public API: availability first, then a booking. The server re-checks everything. */
export function BookingForm({ services }: { services: Service[] }) {
  const [service, setService] = useState(services[0]?.id ?? '');
  const [slots, setSlots] = useState<Slot[] | null>(null);
  const [message, setMessage] = useState('');
  const [form, setForm] = useState({ name: '', email: '' });

  const key = process.env.NEXT_PUBLIC_NISE_KEY;

  async function loadSlots() {
    setMessage('');
    const today = new Date().toISOString().slice(0, 10);
    const res = await fetch(`${API}/api/public/${key}/slots?service=${service}&from=${today}&days=7`);
    const body = await res.json();
    setSlots(body.days?.flatMap((d: { slots: Slot[] }) => d.slots) ?? []);
    if (!res.ok) setMessage(`Tijden laden lukte niet (${res.status}).`);
  }

  async function book(slot: Slot) {
    setMessage('Bezig…');
    const res = await fetch(`${API}/api/public/${key}/bookings`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ items: [{ service, staff: slot.staffId, start: slot.start }], ...form, phone: '', notes: '' }),
    });
    const body = await res.json();
    if (res.status === 201) return setMessage(`Geboekt: ${new Date(slot.start).toLocaleString('nl-NL')}.`);
    // 409 slot_unavailable = someone was first. Never retry the same slot; fetch fresh times.
    if (res.status === 409) { setMessage('Dat tijdslot was net bezet. Kies een nieuw tijdstip.'); return void loadSlots(); }
    const field = body.fields && Object.keys(body.fields)[0];
    setMessage(field ? `${field}: ${body.fields[field]}` : `Boeken lukte niet (${res.status}).`);
  }

  if (!services.length) return <p>Deze account heeft nog geen diensten.</p>;
  return (
    <section>
      <label>
        Dienst:{' '}
        <select value={service} onChange={(e) => { setService(e.target.value); setSlots(null); }}>
          {services.map((s) => <option key={s.id} value={s.id}>{s.title} — {s.duration} min</option>)}
        </select>
      </label>
      <p>
        <label>Naam <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>{' '}
        <label>E-mail <input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></label>
      </p>
      <p><button onClick={loadSlots}>Toon vrije tijden</button></p>
      {slots && (
        <ul>
          {slots.map((s) => (
            <li key={s.start}>
              <button onClick={() => book(s)} disabled={!form.name || !form.email}>
                {new Date(s.start).toLocaleString('nl-NL', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
              </button>
            </li>
          ))}
          {!slots.length && <li>Geen vrije tijden in de komende week.</li>}
        </ul>
      )}
      {message && <p role="status">{message}</p>}
    </section>
  );
}

What the code is careful about:

4. Pitfalls specific to Next.js

5. Run the example

git clone --depth 1 https://github.com/spoetnik/nisebookings.git   # or copy examples/nextjs
cd examples/nextjs && cp .env.example .env.local                    # paste the key
npm install && npm run dev                                          # http://localhost:3000

Next steps: notifications and webhooks for reacting to bookings in your backend, the widget reference for every attribute and token, and the sandbox if you want to test without touching a real account.