Building the Chat Interface: React, SSE, and shadcn
An event-driven chat frontend embedded into a single deployable binary.
The server streams structured JSON events over SSE. Now we need something on the other end to consume them. This article covers the frontend: project setup, the SSE client pattern for POST-based streams, the component hierarchy for rendering agent events, and how the final build gets embedded into the Rust binary as a single deployable artifact.
The frontend is still under active development. We will show the architectural decisions, the SSE consumption pattern in full, and the embedding mechanism -- but leave individual component implementations as reader exercises.
Project Setup
The frontend uses Vite 7 with React 19, Tailwind CSS 4, and shadcn components in the base-mira style. TanStack Router provides file-based routing.
Scaffolding
bun create vite frontend -- --template react-ts
cd frontend
bun installThen add the key dependencies:
bun add @tanstack/react-router @tanstack/react-router-devtools
bun add -D @tanstack/router-plugin
bun add tailwindcss @tailwindcss/vite tw-animate-css
bun add class-variance-authority clsx tailwind-merge lucide-react
bun add shadcnVite Configuration
The Vite config wires together three plugins: TanStack Router (for automatic route tree generation with code splitting), React, and Tailwind CSS 4's native Vite plugin. The @ path alias maps to ./src for clean imports.
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import { defineConfig } from "vite"
export default defineConfig({
plugins: [tanstackRouter({
target: 'react',
autoCodeSplitting: true,
}),,react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})shadcn Configuration
The components.json file tells shadcn how to generate components. We use the base-mira style (Base UI primitives), no RSC (this is a client-side SPA), and register the AI Elements registry for agent-specific UI components:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-mira",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json"
}
}File-Based Routing
TanStack Router generates a route tree from files in src/routes/. The root layout in __root.tsx provides a full-screen dark container:
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
const RootLayout = () => (
<div className="fixed inset-0 w-screen h-screen bg-black overflow-hidden">
<Outlet />
<TanStackRouterDevtools />
</div>
)
export const Route = createRootRoute({ component: RootLayout })The entry point creates the router and mounts it:
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "./index.css"
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)The routeTree.gen file is auto-generated by the TanStack Router Vite plugin -- you never edit it by hand. Add a new file under src/routes/ and the plugin picks it up on the next hot reload.
SSE Consumption: POST-Based Streaming
The browser's built-in EventSource API only supports GET requests. Our chat endpoint is POST /api/chat because it sends a JSON body with the message and session ID. We need a different approach.
The pattern is: use fetch() to make the POST, then read the response body as a stream via ReadableStream piped through a TextDecoderStream. SSE frames arrive as data: {...json...}\n\n and we parse them incrementally.
Here is the full SSE client pattern:
interface AgentEvent {
type: 'thinking' | 'tool_start' | 'tool_result' | 'retry' | 'response' | 'error';
[key: string]: unknown;
}
type EventHandler = (event: AgentEvent) => void;
async function streamChat(
message: string,
sessionId: string | null,
onEvent: EventHandler,
onDone: () => void,
onError: (error: Error) => void,
): Promise<void> {
let response: Response;
try {
response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
session_id: sessionId,
}),
});
} catch (err) {
onError(err instanceof Error ? err : new Error(String(err)));
return;
}
if (!response.ok || !response.body) {
onError(new Error(`HTTP ${response.status}`));
return;
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
// SSE frames are separated by double newlines
let boundary: number;
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
// Each line in the frame starts with "data: "
const lines = frame.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const json = line.slice(6);
try {
const event: AgentEvent = JSON.parse(json);
onEvent(event);
} catch {
// Skip malformed JSON (e.g., keep-alive comments)
}
}
}
}
}
} catch (err) {
onError(err instanceof Error ? err : new Error(String(err)));
return;
}
onDone();
}A few things to note about this pattern:
Why not EventSource? EventSource only supports GET requests. There is no way to set a request body or custom headers. Since our endpoint requires POST with a JSON body, we must use fetch directly.
Buffering matters. Network chunks do not align with SSE frame boundaries. A single read() call might return half an event, two events, or an event and a half. The buffer accumulates text and we only parse when we see the \n\n delimiter that marks a complete SSE frame.
Keep-alive handling. The server sends SSE keep-alive comments (lines starting with :) every 15 seconds to prevent connection timeouts. The parser ignores these naturally because they don't start with data: .
Error recovery. If the connection drops mid-stream, the reader.read() call will throw. The caller decides whether to retry, prompt the user, or give up. A simple retry strategy is to call streamChat again with the same session ID -- the server-side session preserves conversation history.
Message Rendering: Component Hierarchy
The agent streams six event types over SSE, each corresponding to a phase of the ReAct loop:
| Event | Phase | What It Contains |
|---|---|---|
thinking | Think | LLM's reasoning text before acting |
tool_start | Act (begin) | Label, WAT source code, description |
tool_result | Observe | Execution success/failure and output |
retry | Act (error recovery) | Compilation error, retry attempt number |
response | Final | The agent's answer, session ID, step count |
error | Abort | Error message |
The component hierarchy maps directly to these events:
<Conversation>
<Message type="user">
User's input text
</Message>
<Message type="agent">
<Thinking>
"I need to fetch the URL to get the data..."
</Thinking>
<ToolExecution>
<ToolStart label="http_get" />
<CodeBlock language="wat">
(local $url i32) (local $resp i32)
...
</CodeBlock>
<ToolResult success={true}>
{"origin": "1.2.3.4", ...}
</ToolResult>
</ToolExecution>
<Response>
Here is the HTTP response from httpbin.org: ...
</Response>
</Message>
</Conversation>The Conversation component maintains a scrollable list of messages, auto-scrolling to the bottom as new events arrive. Each Message component accumulates events for its ReAct cycle: zero or more Thinking/ToolExecution pairs followed by a terminal Response or Error.
The ToolExecution component is the most visually interesting. It starts in a "running" state when tool_start arrives (showing a spinner and the WAT source in a collapsible code block), then transitions to "complete" when tool_result arrives (showing success/failure status and output). If a retry event arrives between tool_start and tool_result, it displays the compilation error and retry count.
Implementing these components is left as an exercise. The shadcn AI Elements library provides good starting points -- @ai-elements/message for the message container, @ai-elements/code-block for WAT source display, and @ai-elements/chain-of-thought for the thinking phase.
Development Workflow
During development, the frontend and backend run as separate processes. The backend serves only the API (no embedded assets), and CORS is enabled for the Vite dev server's origin:
# Terminal 1: Backend API with CORS for the dev server
export CEREBRAS_API_KEY=your-key-here
cargo run -p cli -- serve --api --allow-origin 5173
# Terminal 2: Frontend dev server with hot reload
cd frontend && bun run devThe --api flag tells the server to skip embedding the frontend and instead add CORS headers allowing requests from http://localhost:5173. The --allow-origin 5173 specifies the port.
The CORS configuration on the server side permits the necessary methods and headers:
let cors = CorsLayer::new()
.allow_origin([format!("http://localhost:{allow_origin}")
.parse()
.unwrap()])
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PUT,
axum::http::Method::DELETE,
axum::http::Method::OPTIONS,
])
.allow_headers([header::ORIGIN, header::CONTENT_TYPE, header::ACCEPT])
.allow_credentials(true);When the --api flag is not set, the CORS layer is not applied and the server serves the embedded frontend instead.
For production, build the frontend first, then compile the Rust binary:
cd frontend && bun install && bun run build && cd ..
cargo build -p cli --releaseThe resulting binary contains everything -- no separate file serving needed.
Embedding the Frontend with rust-embed
The production build gets compiled into the Rust binary using the rust-embed crate. This eliminates the need for a separate static file server or a deployment step for frontend assets.
The entire mechanism fits in one file:
use axum::{
http::{header, StatusCode, Uri},
response::{Html, IntoResponse, Response},
};
use rust_embed::Embed;
#[derive(Embed)]
#[folder = "../frontend/dist/"]
struct Asset;
/// Serve a static file from the embedded assets, or fall back to
/// index.html (SPA routing).
pub async fn static_handler(uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
if let Some(file) = Asset::get(path) {
let mime = mime_guess::from_path(path).first_or_octet_stream();
(
StatusCode::OK,
[(header::CONTENT_TYPE, mime.as_ref())],
axum::body::Body::from(file.data.to_vec()),
)
.into_response()
} else {
index_html().await
}
}
async fn index_html() -> Response {
match Asset::get("index.html") {
Some(file) => {
Html(std::str::from_utf8(&file.data)
.unwrap_or_default()
.to_owned())
.into_response()
}
None => (StatusCode::NOT_FOUND, "index.html not found")
.into_response(),
}
}Three things happen here:
-
Compile-time embedding. The
#[derive(Embed)]macro with#[folder = "../frontend/dist/"]reads all files from the Vite build output at compile time and bakes them into the binary as byte arrays. The resulting executable is fully self-contained. -
MIME type detection. The
mime_guesscrate infers the correctContent-Typeheader from the file extension. JavaScript files getapplication/javascript, CSS files gettext/css, and so on. Unknown extensions fall back toapplication/octet-stream. -
SPA fallback. If the requested path doesn't match any embedded file, the handler returns
index.html. This is critical for client-side routing -- when a user navigates to/chat/abc123and refreshes the page, the server needs to return the SPA shell so that TanStack Router can handle the route on the client side.
The fallback handler is wired into the Axum router via .fallback(), which catches any request that doesn't match an explicit route. In API-only mode (development), the integrations dispatcher takes precedence in the fallback instead.
Adding shadcn Components
shadcn generates component source code directly into your project rather than installing a library. This means you own the code and can modify it freely.
To add a standard UI component:
cd frontend
bunx --bun shadcn@latest add button
bunx --bun shadcn@latest add card
bunx --bun shadcn@latest add scroll-areaComponents are generated into src/components/ui/ following the aliases defined in components.json.
For agent-specific UI, the AI Elements registry provides pre-built components designed for chat interfaces. These are added with a registry prefix:
bunx --bun shadcn@latest add @ai-elements/message
bunx --bun shadcn@latest add @ai-elements/prompt-input
bunx --bun shadcn@latest add @ai-elements/code-block
bunx --bun shadcn@latest add @ai-elements/chain-of-thought
bunx --bun shadcn@latest add @ai-elements/model-selectorAI Elements components land in src/components/ai-elements/ and follow the same pattern -- you get the source code, not a dependency. The index route already uses the prompt-input and model-selector elements to provide the chat input area with model selection and file attachment support.
What Remains
The frontend is the youngest part of the system. The SSE client pattern, embedding mechanism, and component architecture are in place. What remains is wiring the streamChat function to the prompt input, building out the message rendering components, and adding conversation history management. These are standard React state management problems -- the hard part (streaming structured events from an agent runtime to a browser) is solved.