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:
- A plain
<script async src=…>in the layout. React hoists and dedupes it, so it loads once per page and the element upgrades itself when it arrives — independent of whether your own components have hydrated yet.next/scriptis the Next-specific alternative; note that it injects the tag from the client runtime, so a page that has not hydrated does not load it. - The element is a custom element with a shadow DOM. React renders it as an ordinary unknown tag;
page CSS cannot reach inside it. Style it with the host's design tokens instead
(
--nb-accent,--nb-radius, … — full list in the widget reference): set them on:rootor on a wrapping element. - Attributes are strings:
data-keyis required;data-lang,data-service,data-staff,data-packageanddata-apiare optional.
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:
staff: slot.staffId— pass back the staff member the slot was offered for; the server resolves"any"for you, but a slot belongs to one agenda.409 slot_unavailable— somebody else booked that time. Re-fetch, do not retry the same slot.409 capacity_exceededmeans the same thing for a group.- Prices come from the API, never from your own arithmetic:
POST /quotetakes the same items asPOST /bookingsand returns the total, including extras, day services and coupons. - 422 field messages are per field id — see errors; intake
questions come with the service's
fields. - The booking goes straight from the browser to
app.nisebookings.com. That works because every account reflects the request origin when Toegestane websites is empty (default) or contains your site; otherwise the API answers403 origin_not_allowed. Proxying through a route handler is possible but not necessary.
4. Pitfalls specific to Next.js
- Do not wrap the widget in a
<form>— it has its own submit flow, and a nested form is invalid HTML. - Do not render
<nise-booking>insidedangerouslySetInnerHTML: the widget upgrades elements it finds when the script loads, and React must own the node. - A dynamic import (
next/dynamicwithssr: false) is not needed; the element is inert until the script runs. - Don't cache JSON responses of
slots(cache: 'no-store'orrevalidate: 0): stale availability produces 409s.
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.