> ## Documentation Index
> Fetch the complete documentation index at: https://pro-bot.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Next.js example

> A complete Next.js 14 App Router integration of probot-self-hosted, including the server-side chat proxy, client widget, env vars, mounting into the root layout, and streaming considerations.

This is an end-to-end walkthrough for a fresh Next.js 14+ App Router
project. Every file you need lives below - copy it, adjust the persona,
ship.

## Assumptions

* Next.js `14.x` or later, App Router (`app/` directory).
* Node 20+ (for `Response.json`, `fetch`).
* An LLM API key (OpenAI, Grok, LM Studio, or any other
  OpenAI-compatible endpoint).
* Optional: a `pbt_…` bot token if you want dashboard analytics.

## 1. Install

```bash theme={null}
npm i probot-self-hosted
```

The package's peer deps (`react`, `react-dom`) come from your existing
Next.js install.

## 2. Environment variables

Create `.env.local` at the project root (never commit this file):

```bash theme={null}
# .env.local

# Server-only. This is the LLM API key. It must NEVER be prefixed
# NEXT_PUBLIC_* - that would bake it into the client bundle.
OPENAI_API_KEY=sk-…

# Optional. Client-side. Grants conversation + lead writes for one bot on
# the ProBot dashboard. Safe to inline: it's revocable and scoped.
NEXT_PUBLIC_PROBOT_TOKEN=pbt_…
```

Add `.env.local` to `.gitignore` if it isn't already.

## 3. Server-side chat proxy

Create `app/api/probot-chat/route.ts`. This route holds the LLM key and is
the only place your web app calls the provider:

```ts theme={null}
// app/api/probot-chat/route.ts
import { createOpenAIHandler } from "probot-self-hosted/adapters/openai";

// Force Node runtime; the OpenAI-compatible fetch call can take longer
// than the edge runtime's default timeout window on some hosts.
export const runtime = "nodejs";

const send = createOpenAIHandler({
  apiKey: process.env.OPENAI_API_KEY!,
  model: "gpt-4o-mini",
  // Optional overrides:
  // baseUrl: "https://api.x.ai/v1",         // e.g. Grok
  // temperature: 0.5,
  // maxTokens: 800,
});

export async function POST(req: Request) {
  let body: { system?: string; messages?: unknown };
  try {
    body = await req.json();
  } catch {
    return Response.json({ error: "invalid_json" }, { status: 400 });
  }

  if (typeof body.system !== "string" || !Array.isArray(body.messages)) {
    return Response.json({ error: "invalid_shape" }, { status: 400 });
  }

  try {
    const reply = await send({
      system: body.system,
      messages: body.messages as { role: "user" | "assistant"; content: string }[],
    });
    return Response.json({ reply });
  } catch (err) {
    // The handler throws Error("llm_<status>") on non-2xx responses from
    // the provider. Bubble a generic error to the client so we don't leak
    // provider details.
    console.error("[probot-chat]", err);
    return Response.json({ error: "chat_failed" }, { status: 500 });
  }
}
```

<Info>
  Use `export const runtime = "nodejs";` if you're on Vercel and your LLM
  responses might take longer than the edge runtime's response budget. The
  Node runtime has a much longer default timeout.
</Info>

## 4. Client widget

Create `components/BotWidget.tsx`:

```tsx theme={null}
// components/BotWidget.tsx
"use client";

import { ProbotBot } from "probot-self-hosted";

// If your knowledge base is large, host it as a static JSON import or a
// generated file. Keeping the string inline (like this example) is fine
// for a few paragraphs but bloats the client bundle when it's kilobytes.
const CONTEXT = `
I'm Ada Lovelace, a mathematician…

## Skills
Notation, mechanical computation, punch-card design.

## Recent work
Bernoulli numbers on the Analytical Engine.
`.trim();

export function BotWidget() {
  return (
    <ProbotBot
      name="Ada"
      headline="Ask me about my work"
      personality="professional"
      themeColor="#2563eb"
      suggestedQuestions={[
        "What are you working on?",
        "What's your background?",
        "Are you open to collaborating?",
      ]}
      context={CONTEXT}
      customInstructions="Never quote salary numbers; redirect to email."
      captureLead
      sendMessage={async ({ system, messages, signal }) => {
        const res = await fetch("/api/probot-chat", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ system, messages }),
          signal,
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error ?? "chat_failed");
        return data.reply;
      }}
      dashboard={{ token: process.env.NEXT_PUBLIC_PROBOT_TOKEN! }}
    />
  );
}
```

Notice: `sendMessage` passes the `AbortSignal` through so a component
unmount (e.g. route change while a reply is in flight) cancels the pending
fetch cleanly.

## 5. Mount into the root layout

Mount the widget once, in `app/layout.tsx`, so every page carries the
floating chat bubble:

```tsx theme={null}
// app/layout.tsx
import { BotWidget } from "@/components/BotWidget";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <BotWidget />
      </body>
    </html>
  );
}
```

That's it - `npm run dev`, visit `http://localhost:3000`, click the chat
bubble.

## Loading the knowledge base from a file

Inlining a multi-KB string bloats the client bundle. Store the knowledge
as a JSON or Markdown file and import it:

```tsx theme={null}
// components/BotWidget.tsx
"use client";

import { ProbotBot } from "probot-self-hosted";
// Next.js transpiles the JSON import; the string ends up in the client bundle.
import knowledge from "@/data/bot-knowledge.json";

export function BotWidget() {
  return (
    <ProbotBot
      name="Ada"
      contextChunks={knowledge.chunks}
      // …
    />
  );
}
```

If the knowledge is genuinely large (100+ KB), pull it server-side via a
server component that passes it as a prop to the client widget - keeps
your first-paint JS budget lean.

## Restricting the proxy to your origin

Next.js API routes accept cross-origin requests by default. If your bot's
knowledge is sensitive, add a same-origin check:

```ts theme={null}
// app/api/probot-chat/route.ts (fragment)

const ALLOWED_ORIGINS = new Set([
  process.env.NEXT_PUBLIC_SITE_ORIGIN, // e.g. "https://example.com"
]);

export async function POST(req: Request) {
  const origin = req.headers.get("origin");
  if (origin && !ALLOWED_ORIGINS.has(origin)) {
    return Response.json({ error: "forbidden_origin" }, { status: 403 });
  }
  // …rest of handler
}
```

For an embed-anywhere widget, add CORS headers instead of blocking:

```ts theme={null}
const cors = {
  "access-control-allow-origin": origin ?? "*",
  "access-control-allow-methods": "POST, OPTIONS",
  "access-control-allow-headers": "content-type",
};
```

## Adding per-visitor rate limiting

Every visitor turn = one LLM call = one bill. Rate-limit the proxy per
IP:

```ts theme={null}
// app/api/probot-chat/route.ts (fragment)
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "1 m"),
});

export async function POST(req: Request) {
  const ip =
    req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  const { success } = await ratelimit.limit(ip);
  if (!success) {
    return Response.json({ error: "rate_limited" }, { status: 429 });
  }
  // …rest of handler
}
```

Both Upstash Redis + Ratelimit have generous free tiers; free tier is
plenty for a portfolio bot.

## Streaming responses

The stock `createOpenAIHandler` is non-streaming (returns the full reply
after the LLM finishes). If you want typewriter-style streaming, implement
`sendMessage` yourself with an incremental `TransformStream`:

```ts theme={null}
// app/api/probot-chat/route.ts (streaming variant)
export async function POST(req: Request) {
  const { system, messages } = await req.json();
  const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      model: "gpt-4o-mini",
      stream: true,
      messages: [{ role: "system", content: system }, ...messages],
    }),
  });
  return new Response(upstream.body, {
    headers: { "content-type": "text/event-stream" },
  });
}
```

You'd then swap the built-in `<ProbotBot />` for the headless
`useProbotChat` hook and drive tokens in yourself. See the
[React example](/docs/self-hosted-bot/react#headless-usage) for the hook API.

## Testing locally

```bash theme={null}
# 1. Start the dev server
npm run dev

# 2. Hit the proxy directly to confirm it works before touching the widget
curl -s http://localhost:3000/api/probot-chat \
  -H "content-type: application/json" \
  -d '{"system":"You are a test bot. Reply with the word OK.","messages":[{"role":"user","content":"hi"}]}' | jq
# Expected: { "reply": "OK" }
```

If the curl returns `{ "reply": … }`, the widget will work. If it returns
`{ "error": … }`, fix the proxy first - the widget won't magically make
a broken server route work.

## Deploying

Vercel:

1. Set env vars in the Vercel dashboard: `OPENAI_API_KEY` (encrypted) and
   `NEXT_PUBLIC_PROBOT_TOKEN` (public).
2. Push to your production branch.

Any other host (Fly, Railway, self-managed VPS):

1. Set the same env vars in your platform's secrets store.
2. Ensure the process running Next.js has network egress to the LLM
   provider (`api.openai.com`, `api.x.ai`, etc.).
3. Confirm `/api/probot-chat` responds under production DNS before
   flipping traffic.

## Full example repo

The above files map to a working Next.js 14 project. If you want a
scaffolded starter with all this pre-wired, see the
[`packages/probot-self-hosted`](https://github.com/vishalpatil18/probot/tree/main/packages/probot-self-hosted)
directory in the ProBot monorepo - the package README's Quick example
covers the same setup in a condensed form.
