Atlas

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 ai

2. Configure the API URL

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:3001

Set CORS on the Atlas API side:

# Atlas API .env
ATLAS_CORS_ORIGIN=http://localhost:5173

3. 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:

  • executeSQL output -- { success, columns, rows, truncated } on success, or { success: false, error } on failure. columns is string[], rows is Record<string, unknown>[]. Use rows.length to get the row count.
  • explore output -- 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. Use getToolName(part) to extract the tool name. Do not check part.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.).

On this page