You will build a React chat interface that connects to a self-hosted LLM on your GPU server. By the end, you will have a polished chat UI with streaming responses, message history, markdown rendering, and proper error handling — all powered by your own model with zero per-token costs.
Project Setup
Create a React app with TypeScript and install the dependencies for streaming and markdown rendering.
npm create vite@latest chat-ui -- --template react-ts
cd chat-ui
npm install react-markdown remark-gfm
npm install -D tailwindcss @tailwindcss/typography
API Connection Layer
Build a client that streams responses from your vLLM or Ollama backend using Server-Sent Events.
// src/api/chat.ts
export interface Message {
role: "user" | "assistant" | "system";
content: string;
}
export async function* streamChat(
messages: Message[],
apiUrl = "/api/chat"
): AsyncGenerator<string> {
const response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages,
max_tokens: 1024,
temperature: 0.7,
stream: true,
}),
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ") && line !== "data: [DONE]") {
const data = JSON.parse(line.slice(6));
if (data.content) yield data.content;
}
}
}
}
This async generator works with any backend that serves OpenAI-compatible streaming responses. For backend setup, see the vLLM production guide or the FastAPI server guide.
Chat Component
Build the main chat component with message state management and streaming display.
// src/components/Chat.tsx
import { useState, useRef, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Message, streamChat } from "../api/chat";
export function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
async function handleSend() {
if (!input.trim() || isStreaming) return;
const userMessage: Message = { role: "user", content: input };
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput("");
setIsStreaming(true);
try {
let assistantContent = "";
setMessages([...newMessages, { role: "assistant", content: "" }]);
for await (const token of streamChat(newMessages)) {
assistantContent += token;
setMessages([...newMessages, { role: "assistant", content: assistantContent }]);
scrollToBottom();
}
} catch (error) {
setMessages([...newMessages,
{ role: "assistant", content: "Error: Could not connect to the model server." }
]);
} finally {
setIsStreaming(false);
}
}
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] p-3 rounded-lg ${
msg.role === "user" ? "bg-blue-600 text-white" : "bg-gray-100 prose"}`}>
{msg.role === "assistant" ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
) : msg.content}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
<div className="p-4 border-t flex gap-2">
<input value={input} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message..." className="flex-1 p-3 border rounded-lg" />
<button onClick={handleSend} disabled={isStreaming}
className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50">
{isStreaming ? "..." : "Send"}
</button>
</div>
</div>
);
}
Message Features
Add copy-to-clipboard, regeneration, and code syntax highlighting for a polished experience.
// src/components/MessageActions.tsx
function MessageActions({ content, onRegenerate }: {
content: string;
onRegenerate: () => void;
}) {
const [copied, setCopied] = useState(false);
async function copyToClipboard() {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="flex gap-2 mt-1 text-sm text-gray-500">
<button onClick={copyToClipboard}>{copied ? "Copied" : "Copy"}</button>
<button onClick={onRegenerate}>Regenerate</button>
</div>
);
}
Model Settings Panel
Let users adjust inference parameters without touching code.
// src/components/Settings.tsx
interface ModelSettings {
temperature: number;
maxTokens: number;
topP: number;
systemPrompt: string;
}
function SettingsPanel({ settings, onChange }: {
settings: ModelSettings;
onChange: (s: ModelSettings) => void;
}) {
return (
<div className="p-4 border-l w-72">
<h3 className="font-bold mb-4">Model Settings</h3>
<label>Temperature: {settings.temperature}</label>
<input type="range" min="0" max="2" step="0.1"
value={settings.temperature}
onChange={(e) => onChange({ ...settings, temperature: +e.target.value })} />
<label>Max Tokens: {settings.maxTokens}</label>
<input type="range" min="64" max="4096" step="64"
value={settings.maxTokens}
onChange={(e) => onChange({ ...settings, maxTokens: +e.target.value })} />
<label>System Prompt</label>
<textarea value={settings.systemPrompt}
onChange={(e) => onChange({ ...settings, systemPrompt: e.target.value })}
className="w-full p-2 border rounded" rows={4} />
</div>
);
}
Deployment
Build the React app and serve it alongside your inference API.
# Build
npm run build
# Serve with the FastAPI or Flask backend
# Static files from dist/ served by Nginx
# API requests proxied to the inference server
For a full-stack framework approach, see the Next.js guide. For real-time communication alternatives, check WebSockets for AI. Add GPU monitoring to track inference performance. The self-hosting guide covers infrastructure, and our tutorials section has more UI patterns. Compare backends with vLLM vs Ollama.
Build AI Chat UIs on Dedicated GPUs
Deploy React chat apps powered by self-hosted LLMs on bare-metal GPU servers. Zero API fees, full control.
Browse GPU Servers