Cookbook

Browser chat with Next.js

A small Next.js chat app: the page streams the answer, and the key never leaves your server.

Download and configure

Install Python 3 or Node 22 and download the complete examples from the recipe archive. The archive has the files shown below, including the browser app and local stream checks. Choose an exact model ID from the public catalogue and export your inference key from a secret store.

bash
curl --fail "https://aihub.belive.sg/docs/examples/tokenhub.zip" -o tokenhub-examples.zip
unzip tokenhub-examples.zip
cd tokenhub-examples
cp .env.example .env
# Edit .env: set TOKENHUB_API_KEY and TOKENHUB_MODEL.
# Keep .env local. Scripts read process environment, not the .env file itself.
set -a
. ./.env
set +a
# Use the API base for the environment shown by this docs site.
export TOKENHUB_BASE_URL="https://tokenhub-api.belive.sg/api/v1"

The sample has no sign-in. It binds to 127.0.0.1 and refuses to run as a production server unless you set TOKENHUB_SAMPLE_ALLOW_HOSTED=1. Set that only after adding your own authentication in front of /api/chat; otherwise anyone who reaches it spends your key.

Run it

bash
cd browser-chat
npm install
npm run dev
# open http://127.0.0.1:3005

The server route

It bounds each request to 20 messages of at most 8,000 characters, sends the stream request with the key, and passes the event stream through. The browser's abort signal is passed on, so pressing Stop or closing the tab cancels the AI Hub request too.

typescript
// The server half. The AI Hub key stays here; the browser never sees it.
//
// This sample has no sign-in. It refuses to run as a production server unless you set
// TOKENHUB_SAMPLE_ALLOW_HOSTED=1, which you should do only after putting your own
// authentication in front of this route. Otherwise anyone who can reach it spends your key.

const MAX_MESSAGES = 20;
const MAX_CHARS = 8000;

type Message = { role: "user" | "assistant"; content: string };

function bad(status: number, message: string) {
  return Response.json({ error: { message } }, { status });
}

export async function POST(req: Request) {
  if (process.env.NODE_ENV === "production" && process.env.TOKENHUB_SAMPLE_ALLOW_HOSTED !== "1")
    return bad(403, "This sample has no authentication. Add your own before hosting it.");
  const base = (process.env.TOKENHUB_BASE_URL ?? "").replace(/\/+$/, "");
  const key = process.env.TOKENHUB_API_KEY;
  const model = process.env.TOKENHUB_MODEL;
  if (!base || !key || !model) return bad(500, "Set TOKENHUB_BASE_URL, TOKENHUB_API_KEY and TOKENHUB_MODEL.");

  let messages: Message[];
  try {
    const body = await req.json();
    messages = body.messages;
  } catch {
    return bad(400, "Send a JSON body with a messages array.");
  }
  // Bound what one request may send upstream.
  if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGES)
    return bad(400, `Send 1 to ${MAX_MESSAGES} messages.`);
  for (const m of messages)
    if ((m?.role !== "user" && m?.role !== "assistant") || typeof m.content !== "string" || m.content.length > MAX_CHARS)
      return bad(400, `Each message needs role user or assistant and at most ${MAX_CHARS} characters.`);

  // req.signal aborts when the browser disconnects, which cancels the AI Hub request too.
  const upstream = await fetch(`${base}/chat/completions`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({ model, messages, stream: true, max_tokens: 1000 }),
    signal: req.signal,
  });
  if (!upstream.ok || !upstream.body) {
    const text = await upstream.text().catch(() => "");
    let message = `AI Hub answered HTTP ${upstream.status}`;
    try { message = JSON.parse(text).error.message ?? message; } catch { /* not JSON */ }
    return bad(upstream.status === 401 ? 500 : 502, message);
  }
  // Pass the SSE bytes through unchanged; the page parses them with the same rules.
  return new Response(upstream.body, {
    headers: { "content-type": "text/event-stream", "cache-control": "no-store",
      "x-generation-id": upstream.headers.get("x-generation-id") ?? "" },
  });
}

The page

tsx
"use client";

import { useRef, useState } from "react";

type Message = { role: "user" | "assistant"; content: string };

/** SSE allows CRLF, LF and CR line endings; normalise to LF, holding a trailing CR for the next read. */
function normalizeLineEndings(buffer: string): string {
  const hold = buffer.endsWith("\r");
  return (hold ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n") + (hold ? "\r" : "");
}

/** Reads the passed-through SSE body. Ends only on [DONE]; an error frame or an early end throws. */
async function readStream(res: Response, onText: (text: string) => void): Promise<void> {
  if (!res.ok || !res.body) {
    const body = await res.json().catch(() => null);
    throw new Error(body?.error?.message ?? `HTTP ${res.status}`);
  }
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer = normalizeLineEndings(buffer + decoder.decode(value, { stream: true }));
    let end: number;
    while ((end = buffer.indexOf("\n\n")) !== -1) {
      const frame = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);
      const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n");
      if (data === "") continue; // keep-alive comment
      if (data === "[DONE]") return;
      const chunk = JSON.parse(data);
      if (chunk.error) throw new Error(chunk.error.message ?? "The answer failed.");
      const text = chunk.choices?.[0]?.delta?.content; // the usage chunk has an empty choices array
      if (text) onText(text);
    }
  }
  if (buffer.replace(/\r$/, "").trim() !== "data: [DONE]") throw new Error("The answer was cut off before it finished.");
}

export default function Page() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [draft, setDraft] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);
  const abort = useRef<AbortController | null>(null);

  async function send() {
    const text = draft.trim();
    if (!text || busy) return;
    const history: Message[] = [...messages, { role: "user" as const, content: text }].slice(-20);
    setMessages([...history, { role: "assistant", content: "" }]);
    setDraft("");
    setError(null);
    setBusy(true);
    abort.current = new AbortController();
    try {
      const res = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages: history }),
        signal: abort.current.signal,
      });
      await readStream(res, (delta) =>
        setMessages((all) => [...all.slice(0, -1), { role: "assistant", content: all[all.length - 1].content + delta }]));
    } catch (err) {
      // Drop the unfinished answer so the next turn does not build on it.
      setMessages(history);
      setError((err as Error).name === "AbortError" ? "Stopped." : (err as Error).message);
    } finally {
      setBusy(false);
      abort.current = null;
    }
  }

  return (
    <main style={{ maxWidth: 720, margin: "0 auto", padding: 16 }}>
      <h1 style={{ fontSize: 20 }}>AI Hub chat</h1>
      <ol style={{ listStyle: "none", padding: 0 }}>
        {messages.map((m, i) => (
          <li key={i} style={{ margin: "8px 0", whiteSpace: "pre-wrap" }}>
            <b>{m.role === "user" ? "You" : "Model"}:</b> {m.content}
          </li>
        ))}
      </ol>
      {error ? <p role="alert" style={{ color: "#b00020" }}>{error}</p> : null}
      <form onSubmit={(e) => { e.preventDefault(); void send(); }} style={{ display: "flex", gap: 8 }}>
        <input value={draft} onChange={(e) => setDraft(e.target.value)} maxLength={8000}
          aria-label="Message" style={{ flex: 1, minWidth: 0, padding: 8 }} />
        {busy
          ? <button type="button" onClick={() => abort.current?.abort()}>Stop</button>
          : <button type="submit">Send</button>}
      </form>
    </main>
  );
}

Dependencies

json
{
  "name": "tokenhub-browser-chat",
  "private": true,
  "version": "1.0.0",
  "scripts": {
    "dev": "next dev -H 127.0.0.1 -p 3005",
    "build": "next build",
    "start": "next start -H 127.0.0.1 -p 3005"
  },
  "dependencies": {
    "next": "15.5.27",
    "react": "19.1.1",
    "react-dom": "19.1.1"
  },
  "devDependencies": {
    "@types/node": "22.15.0",
    "@types/react": "19.1.0",
    "typescript": "5.9.2"
  }
}