React Setup
Now let's build the frontend. We'll create a modern chat interface using React, Vite, and shadcn/ui.
Project Structure
frontend/
├── src/
│ ├── main.tsx # Entry point
│ ├── App.tsx # Main component
│ ├── App.css # App styles
│ ├── index.css # Global styles (Tailwind)
│ ├── types.ts # TypeScript types
│ ├── components/
│ │ ├── Header.tsx
│ │ ├── ChatMessage.tsx
│ │ ├── ChatComposer.tsx
│ │ ├── PreferencesDialog.tsx
│ │ └── ui/ # shadcn components
│ ├── hooks/
│ │ └── useChat.ts # Chat state management
│ └── lib/
│ ├── utils.ts # Utility functions
│ └── api.ts # API client
└── ...config files
Step 1: Types
Create src/types.ts:
src/types.ts
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
export interface Preferences {
dietary_type: string;
spice_level: string;
allergies: string[];
dislikes: string[];
health_goals: string[];
preferred_cuisines: string[];
}
Step 2: Utilities
Create src/lib/utils.ts:
src/lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function generateId(): string {
return Math.random().toString(36).substring(7);
}
The cn function merges Tailwind classes intelligently:
cn('px-4 py-2', 'px-6') // Returns 'py-2 px-6' (px-6 wins)
cn('bg-red-500', conditional && 'bg-blue-500') // Conditional classes
Step 3: API Client
Create src/lib/api.ts:
src/lib/api.ts
import type { Preferences } from '@/types';
export async function* streamChat(
message: string,
preferences: Preferences
): AsyncGenerator<string, void, unknown> {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, preferences }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.error) {
throw new Error(data.error);
}
if (data.content) {
yield data.content;
}
if (data.done) {
return;
}
} catch (e) {
// Ignore JSON parse errors for incomplete chunks
}
}
}
}
}
Understanding the Stream Client
This is an async generator that yields tokens as they arrive:
// Usage
for await (const token of streamChat(message, prefs)) {
setContent(prev => prev + token); // Append each token
}
Key parts:
- Fetch with streaming - We read the response body incrementally
- Parse SSE format - Extract JSON from
data: {...}lines - Yield tokens - Each token is yielded to the caller
- Handle completion - Return when
done: trueis received
Step 4: Entry Point
Update src/main.tsx:
src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Step 5: Global Styles
Your src/index.css should have:
src/index.css
@import "tailwindcss";
@theme {
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--color-slate-950: #020617;
--color-slate-900: #0f172a;
--color-slate-800: #1e293b;
--color-slate-700: #334155;
--color-slate-600: #475569;
--color-slate-500: #64748b;
--color-slate-400: #94a3b8;
--color-slate-300: #cbd5e1;
--color-orange-500: #f97316;
--color-orange-600: #ea580c;
--color-amber-500: #f59e0b;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
}
body {
font-family: var(--font-sans);
background: #020617;
color: #f1f5f9;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
Step 6: Path Aliases
Make sure your tsconfig.app.json has path aliases:
tsconfig.app.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
And vite.config.ts:
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
});
The proxy routes /api/* requests to the backend during development.
Verify Setup
cd frontend
bun run dev
Open http://localhost:5173. You should see an empty page (we haven't built App.tsx yet).
Next, let's add shadcn/ui components.