RTX 3050 - Order Now
Home / Blog / Tutorials / React + Self-Hosted LLM: Chat UI
Tutorials

React + Self-Hosted LLM: Chat UI

Complete guide to building a React chat UI for a self-hosted LLM covering streaming responses, message history, markdown rendering, and connection to vLLM or Ollama on GPU servers.

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

Need a Dedicated GPU Server?

Deploy from RTX 3050 to RTX 5090. Full root access, NVMe storage, 1Gbps — UK datacenter.

Browse GPU Servers

gigagpu

We benchmark, deploy, and optimise GPU infrastructure for AI workloads. All data in our guides comes from real-world testing on our UK-based dedicated GPU servers.

Ready to deploy your AI workload?

Dedicated GPU servers from our UK datacenter. NVMe storage, 1Gbps networking, full root access.

Browse GPU Servers Contact Sales

Have a question? Need help?