Frontend Integration
Add an agent chat to your Next.js app with the React SDK — install, create a token route, drop in the chat component.
Install
pnpm add @21st-sdk/nextjs
# also installs @21st-sdk/react, ai, @ai-sdk/reactThis also installs @21st-sdk/react with all UI components. Peer dependencies: ai, @ai-sdk/react.
Token route
The SDK exchanges your secret API key for short-lived JWT tokens server-side, so credentials are never exposed to the browser.
// app/api/an-token/route.ts
import { createTokenHandler } from "@21st-sdk/nextjs/server"
export const POST = createTokenHandler({
apiKey: process.env.API_KEY_21ST!,
})API_KEY_21ST in your .env.local. Get your key from the dashboard. Never expose this key in client-side code.Chat component
Create a chat instance with createAgentChat, connect it to useChat from AI SDK, and render AgentChat.
"use client"
import { useChat } from "@ai-sdk/react"
import { createAgentChat, AgentChat } from "@21st-sdk/nextjs"
import "@21st-sdk/react/styles.css"
const chat = createAgentChat({
agent: "my-agent",
tokenUrl: "/api/an-token",
})
export default function Page() {
const { messages, input, handleInputChange, handleSubmit, status, stop, error } =
useChat({ chat })
return (
<AgentChat
messages={messages}
onSend={(msg) => handleSubmit(undefined, { body: msg })}
status={status}
onStop={stop}
error={error ?? undefined}
/>
)
}Don't forget to import @21st-sdk/react/styles.css — it provides the base styles for the chat UI.
createAgentChat options
const chat = createAgentChat({
// Required
agent: "my-agent",
// Token exchange — pick one:
tokenUrl: "/api/an-token", // POST endpoint returning { token, expiresAt }
// getToken: async () => "...", // custom token fetcher
// Optional
apiUrl: "https://relay.an.dev", // default relay URL
sandboxId: "sbx_...", // persistent sandbox for file/session sharing
threadId: "thr_...", // specific thread within sandbox
onFinish: () => {}, // called when agent finishes
onError: (error) => {}, // called on error
})| Option | Description |
|---|---|
agent | Agent slug from the dashboard (required) |
tokenUrl | POST endpoint that returns { token, expiresAt }. Tokens are cached for 1 min. |
getToken | Custom async function returning a token string. Alternative to tokenUrl. |
sandboxId | Persistent sandbox ID — lets the agent access the same filesystem across sessions. |
threadId | Thread within the sandbox — separate conversation with shared files. |
AgentChat props
The AgentChat component renders the full chat UI — messages, input bar, tool visualizations, and streaming.
| Prop | Description |
|---|---|
messages | Chat messages from useChat() (required) |
onSend | Called when user sends a message (required) |
status | Chat status from useChat() (required) |
onStop | Stop generation callback (required) |
theme | CSS variable overrides for light/dark themes |
colorMode | "light" | "dark" | "auto" |
classNames | Per-element CSS class overrides (root, messageList, inputBar, etc.) |
slots | Replace sub-components: InputBar, UserMessage, AssistantMessage, ToolRenderer |
toolRenderers | Custom renderers for specific tool names |
modelSelector | Show a model picker if the agent allows model switching |
attachments | File upload configuration (allowed types, max size) |
Custom tool renderers
When your agent calls a custom tool, render the result with your own React component instead of the default JSON view.
import type { CustomToolRendererProps } from "@21st-sdk/react"
function WeatherRenderer({ name, input, output, status }: CustomToolRendererProps) {
if (status === "pending" || status === "streaming") {
return <div>Fetching weather for {input.city as string}...</div>
}
const data = output as { temp: number; condition: string }
return (
<div className="rounded-lg border p-3">
<p className="font-medium">{input.city as string}</p>
<p>{data.temp}°F — {data.condition}</p>
</div>
)
}
// Pass to AgentChat
<AgentChat
toolRenderers={{ get_weather: WeatherRenderer }}
{...rest}
/>| Prop | Type | Description |
|---|---|---|
name | string | Tool name |
input | Record<string, unknown> | Tool input arguments |
output | unknown | undefined | Tool output (undefined while pending) |
status | "pending" | "streaming" | "success" | "error" | Execution status |
Per-message options
Override agent runtime settings for individual messages — switch models, limit cost, or restrict tools for specific tasks.
import { useChat } from "@ai-sdk/react"
const { handleSubmit } = useChat({ chat })
// Override agent options for a specific message
handleSubmit(undefined, {
body: {
role: "user",
content: "Review this code for bugs",
options: {
model: "claude-opus-4-6",
maxTurns: 4,
maxBudgetUsd: 0.5,
disallowedTools: ["Bash", "Write"],
systemPrompt: {
type: "preset",
preset: "claude_code",
append: "You are reviewing code. Do not edit files.",
},
},
},
})Persistent sandboxes and threads
For apps where the agent needs to remember files or hold multiple conversations, create a sandbox on the server and pass its ID to the chat. Use threads for separate conversations within the same sandbox.
"use client"
import { useState, useEffect, useMemo } from "react"
import { useChat } from "@ai-sdk/react"
import { createAgentChat, AgentChat } from "@21st-sdk/nextjs"
import "@21st-sdk/react/styles.css"
export default function Page() {
const [sandboxId, setSandboxId] = useState<string | null>(null)
const [threadId, setThreadId] = useState<string | null>(null)
// Create sandbox on mount
useEffect(() => {
const stored = localStorage.getItem("sandboxId")
if (stored) {
setSandboxId(stored)
return
}
fetch("/api/agent/sandbox", { method: "POST" })
.then((r) => r.json())
.then(({ sandboxId }) => {
localStorage.setItem("sandboxId", sandboxId)
setSandboxId(sandboxId)
})
}, [])
// Create or load thread
useEffect(() => {
if (!sandboxId) return
fetch(`/api/agent/threads?sandboxId=${sandboxId}`)
.then((r) => r.json())
.then(({ threads }) => {
if (threads.length > 0) {
setThreadId(threads[0].id)
} else {
return fetch("/api/agent/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sandboxId, name: "Main" }),
}).then((r) => r.json())
.then(({ id }) => setThreadId(id))
}
})
}, [sandboxId])
const chat = useMemo(() => {
if (!sandboxId || !threadId) return null
return createAgentChat({
agent: "my-agent",
tokenUrl: "/api/an-token",
sandboxId,
threadId,
})
}, [sandboxId, threadId])
const { messages, handleSubmit, status, stop, error } = useChat({
chat: chat ?? undefined,
})
if (!chat) return <div>Loading...</div>
return (
<AgentChat
messages={messages}
onSend={(msg) => handleSubmit(undefined, { body: msg })}
status={status}
onStop={stop}
error={error ?? undefined}
/>
)
}The sandbox and thread API routes use the Server SDK. See the backend integration page for the full API.