Skip to main content

    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

    bash
    npx create-next-app@latest behest-chat --ts --app
    cd behest-chat
    npm i @behest/client-ts@beta openai

    .env.local:

    bash
    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.app

    3. Token route handler

    Mints a per-user JWT using your existing session. Example uses NextAuth — swap for Clerk/Supabase/whatever.

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

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

    tsx
    // 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>
      );
    }
    tsx
    // 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

    bash
    npm run dev

    Visit /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-Origin on 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.
    • curl works but browser fails → always CORS. Browsers enforce it; curl doesn't.

    Next steps

    Enterprise Token FinOps: Enforce hard budgets and attribute costs per session.

    Learn more