React hooks

`useChat()` and `useCompletion()` for client-side chat UIs — with the right key-handling pattern.

@cleverrouter/sdk/react exposes two hooks for client-side chat UIs, shaped after the Vercel AI SDK equivalents. They talk to CleverRouter directly via our typed stream helper.

API keys never go to the browser

These hooks accept a config.apiKey at runtime. That key must come from a short-lived token issued by your backend — never from a build-time env var that ships in the JS bundle. See Authentication.

At a glance

Installbun add @cleverrouter/sdk@alpha react
HooksuseChat() (history), useCompletion() (single-shot)
StreamingBuilt-in, with stop() to abort
Recommended patternBackend /api/cr-token issues ephemeral key

useChat()

components/Chat.tsx
'use client';

import { useChat } from '@cleverrouter/sdk/react';

export function Chat({ apiKey }: { apiKey: string }) {
  const {
    messages,
    input,
    isLoading,
    error,
    handleInputChange,
    handleSubmit,
    stop,
    reload,
  } = useChat({
    config: { apiKey },
    model: 'mistral/mistral-small-3.2',
    initialMessages: [
      { role: 'system', content: 'You are a helpful EU-savvy assistant.' },
    ],
    onFinish: (msg) => console.log('done', msg),
  });

  return (
    <div className="space-y-3">
      <ul className="space-y-2">
        {messages.map((m, i) => (
          <li key={i}>
            <b>{m.role}:</b>{' '}
            {typeof m.content === 'string' ? m.content : '[parts]'}
          </li>
        ))}
      </ul>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          disabled={isLoading}
          className="flex-1 rounded border px-3 py-2"
          placeholder="Ask anything…"
        />
        <button type="submit" disabled={isLoading}>Send</button>
        {isLoading && (
          <button onClick={stop} type="button">Stop</button>
        )}
      </form>

      {error ? <p className="text-red-500">{String(error)}</p> : null}

      <button onClick={reload} type="button">Regenerate</button>
    </div>
  );
}

Returned helpers

FieldPurpose
messagesCurrent chat history (system / user / assistant)
inputControlled input value
setInputProgrammatic input control
setMessagesReplace the history (e.g. on conversation switch)
isLoadingtrue while a stream is in flight
errorLast thrown error (typed CR* class)
handleInputChangeWire to <input onChange>
handleSubmitWire to <form onSubmit>
append(msg)Push a message and start a new completion
reload()Drop the last assistant turn and regenerate
stop()Abort the in-flight stream

useCompletion()

Single-shot, no history:

components/Completion.tsx
'use client';

import { useCompletion } from '@cleverrouter/sdk/react';

export function Completion({ apiKey }: { apiKey: string }) {
  const {
    completion,
    input,
    isLoading,
    handleInputChange,
    handleSubmit,
    stop,
  } = useCompletion({
    config: { apiKey },
    model: 'mistral/mistral-small-3.2',
    onFinish: (text) => console.log('final', text),
  });

  return (
    <form onSubmit={handleSubmit} className="space-y-2">
      <input value={input} onChange={handleInputChange} disabled={isLoading} />
      <pre className="whitespace-pre-wrap">{completion}</pre>
      {isLoading && <button onClick={stop} type="button">Stop</button>}
    </form>
  );
}

Server-side token endpoint pattern

Issue an ephemeral key from your own backend; the client never sees a long-lived cr_live_* key.

app/api/cr-token/route.ts
import { NextResponse } from 'next/server';
import { requireSession } from '@/lib/session';

export async function POST() {
  const session = await requireSession();
  // For now, the existing CR key from a server-only env var.
  // Plan to migrate to per-session ephemeral tokens before GA.
  return NextResponse.json({
    apiKey: process.env.CLEVERROUTER_API_KEY!,
    userId: session.user.id,
  });
}
components/ChatPage.tsx
'use client';

import { useEffect, useState } from 'react';
import { Chat } from './Chat';

export default function ChatPage() {
  const [apiKey, setApiKey] = useState<string | null>(null);
  useEffect(() => {
    fetch('/api/cr-token', { method: 'POST' })
      .then((r) => r.json())
      .then(({ apiKey }) => setApiKey(apiKey));
  }, []);
  return apiKey ? <Chat apiKey={apiKey} /> : <p>Loading…</p>;
}

Common gotchas

  • 'use client' is mandatory. Both hooks read state and run effects.
  • Don't recreate the hook config every render. Memoise config or it triggers a stream restart on every typing keystroke.
  • Stop before unmounting. The hooks cancel automatically on unmount, but if you switch threads inside one mount, call setMessages([]) after stop() to avoid orphan deltas.