React (Vite)
Integrate Atlas into a plain React app using @ai-sdk/react.
Integrate Atlas into a plain React app (Vite, no Next.js) using @ai-sdk/react.
Prerequisites: A running Atlas API server. See Bring Your Own Frontend for architecture and common setup.
1. Install dependencies
bun add @ai-sdk/react ai2. Configure the API URL
Option A: Same-origin proxy (recommended)
Configure Vite's dev server to proxy /api to the Atlas API:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});Option B: Cross-origin
Point directly at the Atlas API:
# .env
VITE_ATLAS_API_URL=http://localhost:3001Set CORS on the Atlas API side:
# Atlas API .env
ATLAS_CORS_ORIGIN=http://localhost:51733. Chat hook
Create a thin wrapper around @ai-sdk/react's useChat:
// src/hooks/use-atlas-chat.ts
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState, useRef, useMemo } from "react";
const API_URL = import.meta.env.VITE_ATLAS_API_URL ?? "";
const isCrossOrigin = !!API_URL;
export function useAtlasChat() {
const [apiKey, setApiKey] = useState(() => {
try {
return sessionStorage.getItem("atlas-api-key") ?? "";
} catch {
return "";
}
});
const [conversationId, setConversationId] = useState<string | null>(null);
const conversationIdRef = useRef(conversationId);
conversationIdRef.current = conversationId;
const transport = useMemo(() => {
const headers: Record<string, string> = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
return new DefaultChatTransport({
api: `${API_URL}/api/chat`,
headers,
// Required for managed auth (cookie-based) when cross-origin
credentials: isCrossOrigin ? "include" : undefined,
// Pass conversationId to continue an existing conversation
body: () =>
conversationIdRef.current
? { conversationId: conversationIdRef.current }
: {},
// Capture x-conversation-id from the response header
fetch: (async (input, init) => {
const response = await globalThis.fetch(input, init);
const convId = response.headers.get("x-conversation-id");
if (convId && convId !== conversationIdRef.current) {
setConversationId(convId);
}
return response;
}) as typeof fetch,
});
}, [apiKey]);
const [input, setInput] = useState("");
const { messages, sendMessage, status, error } = useChat({ transport });
function saveApiKey(key: string) {
setApiKey(key);
try {
sessionStorage.setItem("atlas-api-key", key);
} catch {
// sessionStorage unavailable
}
}
return {
messages,
sendMessage,
status,
error,
input,
setInput,
apiKey,
saveApiKey,
isLoading: status === "streaming" || status === "submitted",
};
}4. Chat component
// src/App.tsx
import { isToolUIPart } from "ai";
import { useAtlasChat } from "./hooks/use-atlas-chat";
import { ToolPart } from "./components/ToolPart";
export default function App() {
const { messages, input, setInput, sendMessage, isLoading, apiKey, saveApiKey } =
useAtlasChat();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim()) return;
const text = input;
setInput("");
sendMessage({ text });
}
return (
<div className="mx-auto max-w-3xl p-4">
<h1 className="mb-4 text-xl font-bold">Atlas</h1>
{/* API key input (only needed for api-key auth mode) */}
<div className="mb-4">
<input
type="password"
placeholder="API key (if required)"
value={apiKey}
onChange={(e) => saveApiKey(e.target.value)}
className="w-full rounded border px-3 py-2 text-sm"
/>
</div>
{/* Messages */}
<div className="space-y-4">
{messages.map((m) => {
if (m.role === "user") {
return (
<div key={m.id} className="flex justify-end">
<div className="rounded-lg bg-blue-600 px-4 py-2 text-white">
{m.parts?.map((part, i) =>
part.type === "text" ? <p key={i}>{part.text}</p> : null
)}
</div>
</div>
);
}
return (
<div key={m.id} className="space-y-2">
{m.parts?.map((part, i) => {
if (part.type === "text" && part.text.trim()) {
return (
<div
key={i}
className="rounded-lg bg-zinc-100 px-4 py-2 text-sm dark:bg-zinc-800"
>
{part.text}
</div>
);
}
if (isToolUIPart(part)) {
return <ToolPart key={i} part={part} />;
}
return null;
})}
</div>
);
})}
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="mt-4 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a question about your data..."
className="flex-1 rounded border px-4 py-2 text-sm dark:bg-zinc-900"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-40"
>
Ask
</button>
</form>
</div>
);
}Markdown rendering: Atlas assistant responses contain Markdown (headers, bold, lists, code blocks, etc.). The example above renders text parts as plain text for simplicity. For production, install
react-markdown(bun add react-markdown) and render text parts with<ReactMarkdown>{part.text}</ReactMarkdown>instead.
5. Rendering tool calls
The chat component above references a ToolPart component. Here is a complete implementation that handles both executeSQL and explore results.
In AI SDK v6, tool parts use per-tool type names ("tool-explore", "tool-executeSQL") or "dynamic-tool" for dynamic tools. Use getToolName(part) from "ai" to extract the tool name regardless of variant, and access input/output/state directly on the part object:
// src/components/ToolPart.tsx
import { useState } from "react";
import { getToolName } from "ai";
export function ToolPart({ part }: { part: unknown }) {
// Use AI SDK v6 helper to extract the tool name from any tool part variant
let toolName: string;
try {
toolName = getToolName(part as Parameters<typeof getToolName>[0]);
} catch {
return <div className="my-2 text-xs text-zinc-500">Tool result (unknown type)</div>;
}
const p = part as Record<string, unknown>;
const state = (p.state as string) ?? "";
const input = (p.input as Record<string, unknown>) ?? {};
const output = p.output;
const done = state === "output-available";
// Loading state
if (!done) {
return (
<div className="my-2 animate-pulse rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900">
{toolName === "executeSQL" ? "Executing query..." : "Running command..."}
</div>
);
}
switch (toolName) {
case "executeSQL":
return <SQLResult input={input} output={output} />;
case "explore":
return <ExploreResult input={input} output={output} />;
default:
return (
<div className="my-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900">
Tool: {toolName}
</div>
);
}
}
function SQLResult({
input,
output,
}: {
input: Record<string, unknown>;
output: unknown;
}) {
const [showSql, setShowSql] = useState(false);
const result = output as Record<string, unknown> | null;
if (!result) {
return (
<div className="my-2 rounded-lg border border-yellow-300 bg-yellow-50 px-3 py-2 text-xs text-yellow-700">
Query completed but no result was returned.
</div>
);
}
if (!result.success) {
return (
<div className="my-2 rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-xs text-red-700">
Query failed: {String(result.error ?? "Unknown error")}
</div>
);
}
const columns = (result.columns as string[]) ?? [];
const rows = (result.rows as Record<string, unknown>[]) ?? [];
const sql = String(input.sql ?? "");
return (
<div className="my-2 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center gap-2 px-3 py-2 text-xs">
<span className="rounded bg-blue-100 px-1.5 py-0.5 font-medium text-blue-700">
SQL
</span>
<span className="flex-1 truncate text-zinc-500">
{String(input.explanation ?? "Query result")}
</span>
<span className="text-zinc-500">
{rows.length} row{rows.length !== 1 ? "s" : ""}
{result.truncated ? "+" : ""}
</span>
</div>
{columns.length > 0 && rows.length > 0 && (
<div className="overflow-x-auto border-t border-zinc-200 dark:border-zinc-700">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
{columns.map((col) => (
<th key={col} className="px-3 py-1.5 font-medium text-zinc-700 dark:text-zinc-300">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-b border-zinc-100 dark:border-zinc-800">
{columns.map((col) => (
<td key={col} className="px-3 py-1.5 text-zinc-600 dark:text-zinc-400">
{row[col] == null ? "\u2014" : String(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{sql && (
<div className="px-3 py-2">
<button
onClick={() => setShowSql(!showSql)}
className="text-xs text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
>
{showSql ? "Hide SQL" : "Show SQL"}
</button>
{showSql && (
<pre className="mt-1 overflow-x-auto rounded bg-zinc-100 p-2 font-mono text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
{sql}
</pre>
)}
</div>
)}
</div>
);
}
function ExploreResult({
input,
output,
}: {
input: Record<string, unknown>;
output: unknown;
}) {
const [open, setOpen] = useState(false);
const command = String(input.command ?? "");
const text = output != null ? String(output) : "(no output)";
const isError = typeof output === "string" && output.startsWith("Error");
return (
<div className="my-2 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
<span className="font-mono text-green-500">$</span>
<span className="flex-1 truncate font-mono text-zinc-700 dark:text-zinc-300">
{command}
</span>
<span className="text-zinc-400">{open ? "\u25BE" : "\u25B8"}</span>
</button>
{open && (
<div className="border-t border-zinc-200 px-3 py-2 dark:border-zinc-700">
<pre
className={`max-h-60 overflow-auto whitespace-pre-wrap font-mono text-xs ${
isError ? "text-red-600 dark:text-red-400" : "text-zinc-500 dark:text-zinc-400"
}`}
>
{text}
</pre>
</div>
)}
</div>
);
}The key data shapes to know:
executeSQLoutput --{ success, columns, rows, truncated }on success, or{ success: false, error }on failure.columnsisstring[],rowsisRecord<string, unknown>[]. Userows.lengthto get the row count.exploreoutput -- a plain string containing the command's stdout, or a string starting with"Error"on failure.state-- check for"output-available"to know when the result is ready. Other states:"input-streaming"(arguments streaming),"input-available"(executing),"output-error"(execution failed),"output-denied"(tool call denied). Show a loading indicator for any state that isn't"output-available"or"output-error".- Tool detection -- use
isToolUIPart(part)from"ai"to check if a part is a tool call. UsegetToolName(part)to extract the tool name. Do not checkpart.type === "tool-invocation"-- that was AI SDK v4/v5. In v6, the type is"tool-{toolName}"or"dynamic-tool". - Markdown -- assistant text parts contain Markdown. For production use, render them with a Markdown library like
react-markdown.
See the Data Stream Protocol section in the overview for the full part structure.
6. Conversation management
Atlas persists conversations server-side (requires DATABASE_URL). Here is a minimal hook for listing, loading, and resuming conversations:
// src/hooks/use-conversations.ts
import { useState, useEffect } from "react";
const API_URL = import.meta.env.VITE_ATLAS_API_URL ?? "";
interface Conversation {
id: string;
userId: string | null;
title: string | null;
surface: string;
connectionId: string | null;
starred: boolean;
createdAt: string;
updatedAt: string;
}
interface Message {
id: string;
role: "user" | "assistant" | "system" | "tool";
content: unknown;
createdAt: string;
}
export function useConversations(apiKey?: string) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(false);
const headers: Record<string, string> = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
async function list() {
setLoading(true);
try {
const res = await fetch(`${API_URL}/api/v1/conversations`, { headers });
if (!res.ok) return;
const data = await res.json();
setConversations(data.conversations ?? []);
} finally {
setLoading(false);
}
}
async function load(id: string): Promise<Message[]> {
const res = await fetch(`${API_URL}/api/v1/conversations/${id}`, { headers });
if (!res.ok) throw new Error("Failed to load conversation");
const data = await res.json();
return data.messages ?? [];
}
async function remove(id: string) {
await fetch(`${API_URL}/api/v1/conversations/${id}`, {
method: "DELETE",
headers,
});
setConversations((prev) => prev.filter((c) => c.id !== id));
}
useEffect(() => { list(); }, [apiKey]);
return { conversations, loading, list, load, remove };
}To resume a conversation, pass the conversationId in the chat request body. The useChat hook from @ai-sdk/react does not manage this automatically, but you can track the ID from the x-conversation-id response header and include it in subsequent requests via the transport's body option.
7. Synchronous queries (alternative)
For non-streaming use cases, call the JSON endpoint directly:
// src/lib/atlas-query.ts
const API_URL = import.meta.env.VITE_ATLAS_API_URL ?? "";
export async function queryAtlas(question: string, apiKey?: string) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${API_URL}/api/v1/query`, {
method: "POST",
headers,
body: JSON.stringify({ question }),
});
if (!res.ok) throw new Error(`Atlas query failed: ${res.status}`);
return res.json();
}See Bring Your Own Frontend for what @atlas/web adds on top of @ai-sdk/react (conversation sidebar, chart detection, managed auth, etc.).