Next.js integration
Wire Hatched into a Next.js App Router app — server components, route handlers, widgets, and webhooks.
Next.js is the most common host for Hatched integrations. The SDK is
server-only, so every call happens in a server component, a route
handler, or middleware — never in a "use client" component.
1. Install and configure
pnpm add @hatched/sdk-js# .env.local
HATCHED_API_KEY=hatch_test_...
HATCHED_WEBHOOK_SECRET=whsec_...2. Shared client
// lib/hatched.ts
import { HatchedClient } from '@hatched/sdk-js';
export const hatched = new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
});Importing this module into a client component won't fail the build — the
SDK enforces server-only usage at runtime: HatchedClient throws inside
its constructor when it detects a browser (the assertServerRuntime guard,
which fires when both window and document are present). Keep it under
lib/ or server/ so it's never pulled into a "use client" bundle. For
true build-time protection, add import 'server-only'; at the top of
lib/hatched.ts — the SDK itself only guards at runtime.
3. Server component reads
// app/buddy/page.tsx
import { hatched } from '@/lib/hatched';
export default async function BuddyPage({ params }: { params: { userId: string } }) {
const buddies = await hatched.buddies.list({ userId: params.userId });
return <BuddyList data={buddies.data} />;
}4. Route handlers for writes
Mutations (events, coin earn/spend, widget session mint) go through route
handlers. They run on the server with access to HATCHED_API_KEY.
// app/api/hatched/events/route.ts
import { hatched } from '@/lib/hatched';
import { ValidationError } from '@hatched/sdk-js';
export async function POST(req: Request) {
const { userId, lessonId, score } = await req.json();
try {
const effects = await hatched.events.send({
eventId: `lesson_${lessonId}_${userId}`,
userId,
type: 'lesson_completed',
properties: { lessonId, score },
});
return Response.json(effects);
} catch (err) {
if (err instanceof ValidationError) {
return Response.json({ error: err.details }, { status: 422 });
}
throw err;
}
}5. Widget session mint endpoint
Your browser widget calls this to get a short-lived token. Never expose your secret API key directly.
// app/api/hatched/session/route.ts
import { hatched } from '@/lib/hatched';
import { getServerSession } from '@/lib/auth';
export async function POST() {
const user = await getServerSession();
if (!user) return new Response('unauthorized', { status: 401 });
const session = await hatched.widgetSessions.create({
buddyId: user.buddyId,
userId: user.id,
scopes: ['read', 'events:track', 'marketplace:browse'],
ttlSeconds: 60 * 15,
});
return Response.json({ token: session.token, expiresAt: session.expiresAt });
}React does not execute a <script> tag rendered as JSX — the loader
would never run. Load it with next/script (strategy="afterInteractive")
instead. The loader reads data-session-token off its own tag, auto-mounts
every data-hatched-mount div, and exposes window.__HATCHED_WIDGET__. For
SPA navigation or token refresh, call init({ token }) again to hot-swap.
// components/buddy-widget.tsx
'use client';
import { useEffect, useState } from 'react';
import Script from 'next/script';
export function BuddyWidget() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
fetch('/api/hatched/session', { method: 'POST' })
.then((r) => r.json())
.then(({ token }) => setToken(token));
}, []);
// First load: the loader auto-mounts off its own data-session-token once the
// script runs, so no manual init is needed. But after a client-side route
// change (or a token refresh) the loader is already running — re-rendering
// the same <Script> src won't re-execute it. Call init() to hot-swap the new
// token into every mounted widget. It safely no-ops if the loader hasn't
// attached yet (the optional chain) and the auto-mount handles that case.
useEffect(() => {
if (token) window.__HATCHED_WIDGET__?.init({ token });
}, [token]);
if (!token) return null;
return (
<>
<Script
src="https://cdn.hatched.live/widget.js"
strategy="afterInteractive"
data-session-token={token}
/>
<div data-hatched-mount="buddy" />
</>
);
}window.__HATCHED_WIDGET__ is set up by the loader script — there is no React
or Vue component to import from @hatched/sdk-js. Add it to the global Window
type so TypeScript is happy:
// types/hatched.d.ts
declare global {
interface Window {
__HATCHED_WIDGET__?: { init: (overrides?: { token?: string }) => void };
}
}
export {};6. Webhook handler
Raw body is critical for signature verification. The verifyNextAppRequest
adapter calls req.text() to read the raw bytes and extracts both the
X-Hatched-Signature and X-Hatched-Timestamp headers for you.
// app/api/hatched/webhooks/route.ts
import { verifyNextAppRequest } from '@hatched/sdk-js';
export const runtime = 'nodejs'; // verifySignature uses node:crypto
export async function POST(req: Request) {
const { valid, event, eventType, deliveryId, reason } = await verifyNextAppRequest(
req,
process.env.HATCHED_WEBHOOK_SECRET!,
);
if (!valid || !event) {
return Response.json({ error: reason ?? 'invalid_signature' }, { status: 400 });
}
if (!deliveryId || !eventType) {
return Response.json({ error: 'missing_webhook_metadata' }, { status: 400 });
}
// event is the raw parsed payload. eventType and deliveryId come from
// X-Hatched-Event / X-Hatched-Delivery headers.
// enqueue for background processing
await handle({ eventType, deliveryId, payload: event });
return new Response(null, { status: 202 });
}7. Middleware gotcha
Next.js Middleware runs in the Edge runtime. @hatched/sdk-js works in
Edge only with publishableKey (read endpoints). For secret-key
writes, move the logic into a runtime = 'nodejs' route handler.
Project layout recap
app/
api/
hatched/
events/route.ts POST — ingest an event
session/route.ts POST — mint widget session token
webhooks/route.ts POST — receive webhook (runtime=nodejs)
buddy/page.tsx server component using hatched.buddies.*
components/
buddy-widget.tsx "use client" — mounts data-hatched-mount="buddy"
lib/
hatched.ts shared HatchedClient instance