# Next.js integration

> Wire Hatched into a Next.js App Router app — server components, route handlers, widgets, and webhooks.

Source: https://docs.hatched.live/docs/guides/nextjs-integration

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

```bash
pnpm add @hatched/sdk-js
```

```bash
# .env.local
HATCHED_API_KEY=hatch_test_...
HATCHED_WEBHOOK_SECRET=whsec_...
```

## 2. Shared client

```ts
// 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

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

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

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

```tsx
// 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:

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

```ts
// 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
```
