Next.js (App Router) Quickstart
Server-side token mint via the Behest v1.5 SDK in a route handler; client-side streaming via the OpenAI SDK directly against Behest. Works with any auth provider — NextAuth, Clerk, Supabase, Auth0.
Prerequisites: Next.js with App Router, Node 18+, a Behest project + key (
behest_sk_live_...), project slug.
1. Configure CORS (don't skip — silent killer)
In dashboard → Project → Settings → Allowed Origins, add every origin that will call Behest from a browser:
http://localhost:3000
https://your-app.vercel.app
https://your-app.com
Click Save. Without this, every browser call fails with a CORS error before reaching the mint endpoint. Protocol + host + port must match exactly; no trailing slash.
2. Install
npx create-next-app@latest behest-chat --ts --app
cd behest-chat
npm i @behest/client-ts@beta openai.env.local:
BEHEST_KEY=behest_sk_live_xxxxxxxxxxxx # server-only
BEHEST_BASE_URL=https://amber-fox-042.behest.app
NEXT_PUBLIC_BEHEST_BASE_URL=https://amber-fox-042.behest.app3. Token route handler
Mints a per-user JWT using your existing session. Example uses NextAuth — swap for Clerk/Supabase/whatever.
// app/api/behest/token/route.ts
import { NextResponse } from "next/server";
import { Behest } from "@behest/client-ts";
import { auth } from "@/auth"; // your NextAuth/Clerk/etc. helper
const behest = new Behest(); // reads BEHEST_KEY + BEHEST_BASE_URL from env
export async function POST() {
const session = await auth();
if (!session?.user?.id)
return new NextResponse("Unauthorized", { status: 401 });
try {
const { token, ttl, sessionId, expiresAt } = await behest.auth.mint({
user_id: session.user.id,
tier: session.user.tier ?? 2, // from your own billing state
ttl: 900,
});
return NextResponse.json({ token, ttl, sessionId, expiresAt });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}Local-signing alternative (no HTTP hop per mint)
Swap BEHEST_KEY=behest_sk_live_... for BEHEST_KEY=behest_pk_... (a tenant RSA private key from dashboard → Keys → Signing) and add BEHEST_KID, BEHEST_TENANT_ID, BEHEST_PROJECT_ID to your env. The SDK auto-detects the mode by key prefix — no code change in the route handler. See auth-modes for the tradeoff.
4. Token helper (client)
// lib/behestToken.ts
"use client";
type TokenBundle = {
token: string;
ttl: number;
sessionId: string;
expiresAt: number;
};
let cached: TokenBundle | null = null;
export async function getBehestToken(): Promise<TokenBundle> {
const now = Math.floor(Date.now() / 1000);
if (cached && cached.expiresAt - now > 60) return cached;
const r = await fetch("/api/behest/token", { method: "POST" });
if (!r.ok) throw new Error(`token fetch failed: ${r.status}`);
cached = (await r.json()) as TokenBundle;
return cached;
}5. Client component
// app/chat/ChatClient.tsx
"use client";
import { useRef, useState } from "react";
import OpenAI from "openai";
import { getBehestToken } from "@/lib/behestToken";
const BASE_URL = `${process.env.NEXT_PUBLIC_BEHEST_BASE_URL}/v1`;
export default function ChatClient({ threadId }: { threadId: string }) {
const [messages, setMessages] = useState<
{ role: "user" | "assistant"; content: string }[]
>([]);
const [input, setInput] = useState("");
const abortRef = useRef<AbortController | null>(null);
async function send() {
abortRef.current = new AbortController();
const next = [...messages, { role: "user" as const, content: input }];
setMessages([...next, { role: "assistant", content: "" }]);
setInput("");
const { token, sessionId } = await getBehestToken();
const openai = new OpenAI({
apiKey: token,
baseURL: BASE_URL,
dangerouslyAllowBrowser: true, // safe: token is short-lived + per-user
defaultHeaders: { "X-Session-Id": sessionId, "X-Thread-Id": threadId },
});
const stream = await openai.chat.completions.create(
{ messages: next, stream: true },
{ signal: abortRef.current.signal }
);
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content ?? "";
setMessages((m) => {
const copy = [...m];
const last = copy[copy.length - 1];
copy[copy.length - 1] = {
role: "assistant",
content: last.content + delta,
};
return copy;
});
}
}
return (
<div>
{messages.map((m, i) => (
<p key={i}>
<b>{m.role}:</b> {m.content}
</p>
))}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
<button onClick={() => abortRef.current?.abort()}>Stop</button>
</div>
);
}// app/chat/page.tsx
import ChatClient from "./ChatClient";
export default function Page() {
// Generate or look up a per-conversation threadId; use nanoid/uuid on first visit.
return <ChatClient threadId={crypto.randomUUID()} />;
}6. Run
npm run devVisit /chat. Check dashboard → Usage: requests appear under the signed-in user's id.
Troubleshooting CORS quickly
If the browser console shows a CORS error, open DevTools → Network → click the failed /v1/chat/completions or /v1/auth/mint request → Headers:
- No
Access-Control-Allow-Originon the response → origin isn't in Allowed Origins. Fix in dashboard, save, retry. - Header present but doesn't match → exact string mismatch (different port, http vs https, trailing slash). Re-check.
curlworks but browser fails → always CORS. Browsers enforce it;curldoesn't.
Next steps
- Multi-conversation chat — list/delete threads
- Integrations: NextAuth · Clerk · Supabase
- Error handling