Frontend and Backend Communication
Introduction
We've built the core RAG pipeline: indexing, retrieval, and generation. Now we need to connect it to users through a well-designed interface. This lesson explores the architecture of frontend-backend communication in a Next.js RAG application.
Understanding the data flow, security boundaries, and communication patterns is essential for building reliable, performant applications.
Next.js Server vs. Client
The Fundamental Distinction
Next.js applications have two execution environments:
Server-Side:
- Runs on your Node.js server
- Has access to environment variables
- Can make secure API calls
- Executes API routes and Server Components
Client-Side:
- Runs in the user's browser
- Only sees public environment variables
- Cannot directly access databases or APIs with secret keys
- Executes Client Components and handles user interaction
What Belongs Where
Must be Server-Side:
- Gemini API calls (API key protection)
- Supabase service role operations
- Vector search queries
- All RAG pipeline operations
Can be Client-Side:
- Chat UI rendering
- User input handling
- Response display
- Loading states
The Golden Rule
Everything that touches an API key or performs privileged database
operations MUST run on the server.
Violating this rule exposes your API keys and allows unauthorized access to your systems.
Data Flow Architecture
Complete Request Lifecycle
┌─────────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Chat Component │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Input │ │ Submit │ │ Display │ │ │
│ │ │ Field │──▶│ Handler │──▶│ Area │ │ │
│ │ └─────────────┘ └──────┬──────┘ └──────▲──────┘ │ │
│ └──────────────────────────┼────────────────┼───────────────────┘ │
└─────────────────────────────┼────────────────┼───────────────────────┘
│ POST │ Stream
▼ │
┌─────────────────────────────────────────────────────────────────────┐
│ SERVER (Next.js API) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ /app/api/chat/route.ts │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Parse │ │ Embed │ │ Search │ │ Generate │ │ │
│ │ │ Request │──▶│ Query │──▶│ Docs │──▶│ Response │ │ │
│ │ └──────────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
│ └─────────────────────┼─────────────┼─────────────┼─────────────┘ │
└─────────────────────────┼─────────────┼─────────────┼─────────────────┘
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Gemini │ │ Supabase │ │ Gemini │
│ Embedding│ │ Vector │ │ LLM │
│ API │ │ Search │ │ API │
└──────────┘ └──────────┘ └──────────┘
Request Structure
The client sends a simple JSON request:
// Client-side request
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: userQuery,
conversationId: currentConversation?.id // Optional
})
});
Response Structure
For streaming responses:
// Server returns a stream
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
For JSON responses (non-streaming):
return Response.json({
answer: generatedText,
sources: sourceDocs.map(d => ({
title: d.title,
source: d.source,
snippet: d.content.slice(0, 200)
})),
conversationId: conversation.id
});
Building the Chat Interface
Component Architecture
ChatPage
├── ChatHeader
│ └── (Title, clear button, settings)
├── MessageList
│ ├── Message (user)
│ ├── Message (assistant)
│ │ └── Sources
│ └── ...
├── TypingIndicator (when loading)
└── ChatInput
├── TextInput
└── SendButton
State Management
// Minimal chat state
interface ChatState {
messages: Message[];
isLoading: boolean;
error: string | null;
}
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
sources?: Source[];
timestamp: Date;
}
interface Source {
title: string;
source: string;
snippet: string;
}
React Component Example
'use client';
import { useState, useRef, useEffect } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
sources?: { title: string; source: string }[];
}
export function ChatInterface() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || isLoading) return;
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: input
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: input })
});
if (!response.ok) {
throw new Error('Failed to get response');
}
// Handle streaming response
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: ''
};
setMessages(prev => [...prev, assistantMessage]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
setMessages(prev =>
prev.map(m =>
m.id === assistantMessage.id
? { ...m, content: m.content + chunk }
: m
)
);
}
} catch (error) {
console.error('Chat error:', error);
setMessages(prev => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: 'Sorry, an error occurred. Please try again.'
}
]);
} finally {
setIsLoading(false);
}
}
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map(message => (
<div
key={message.id}
className={`p-4 rounded-lg ${
message.role === 'user'
? 'bg-blue-100 ml-auto max-w-[80%]'
: 'bg-gray-100 mr-auto max-w-[80%]'
}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
{message.sources && message.sources.length > 0 && (
<div className="mt-2 pt-2 border-t text-sm text-gray-600">
<p className="font-semibold">Sources:</p>
{message.sources.map((s, i) => (
<p key={i}>• {s.title || s.source}</p>
))}
</div>
)}
</div>
))}
{isLoading && (
<div className="bg-gray-100 p-4 rounded-lg mr-auto">
<p className="animate-pulse">Thinking...</p>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Ask a question..."
className="flex-1 p-2 border rounded-lg"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
>
Send
</button>
</div>
</form>
</div>
);
}
Essential UI/UX Patterns
Loading States
Immediate Feedback: Show that the request was received:
// Add user message immediately
setMessages(prev => [...prev, userMessage]);
// Show typing indicator
setIsLoading(true);
Streaming Feedback: Update the response as tokens arrive:
// Progressive content update
setMessages(prev =>
prev.map(m =>
m.id === assistantId
? { ...m, content: m.content + chunk }
: m
)
);
Error Handling
Graceful Degradation:
try {
const response = await fetch('/api/chat', { ... });
if (!response.ok) {
if (response.status === 429) {
throw new Error('Too many requests. Please wait a moment.');
}
if (response.status === 500) {
throw new Error('Server error. Please try again.');
}
throw new Error('Something went wrong.');
}
// ... process response
} catch (error) {
setMessages(prev => [
...prev,
{
id: crypto.randomUUID(),
role: 'assistant',
content: error instanceof Error
? error.message
: 'An unexpected error occurred.'
}
]);
}
Responsive Design
Mobile Considerations:
- Full-height chat on mobile
- Soft keyboard handling
- Touch-friendly buttons
- Readable font sizes
/* Mobile-first chat styles */
.chat-container {
height: 100dvh; /* Dynamic viewport height for mobile */
display: flex;
flex-direction: column;
}
.chat-input {
position: sticky;
bottom: 0;
background: white;
padding-bottom: env(safe-area-inset-bottom); /* iOS safe area */
}
Accessibility
Essential Accessibility Features:
<div
role="log"
aria-live="polite"
aria-label="Chat messages"
>
{messages.map(message => (
<article
key={message.id}
aria-label={`${message.role} message`}
>
{message.content}
</article>
))}
</div>
<form onSubmit={handleSubmit} role="form" aria-label="Send message">
<label htmlFor="chat-input" className="sr-only">
Your message
</label>
<input
id="chat-input"
type="text"
value={input}
onChange={e => setInput(e.target.value)}
aria-describedby="input-hint"
/>
<span id="input-hint" className="sr-only">
Press Enter to send your message
</span>
</form>
Optimistic Updates
Immediate User Feedback
Don't wait for the server to confirm the user's message:
async function sendMessage(content: string) {
// 1. Immediately add user message (optimistic)
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
// 2. Clear input immediately
setInput('');
// 3. Show loading state
setIsLoading(true);
// 4. Then send to server
try {
await fetchAndStreamResponse(content);
} catch (error) {
// 5. Handle errors, but user message is already shown
handleError(error);
}
}
Handling Failures
If the request fails, decide how to handle the optimistic update:
Option 1: Keep user message, show error response:
// User message stays, add error message
setMessages(prev => [
...prev,
{ role: 'assistant', content: 'Failed to get response. Please try again.' }
]);
Option 2: Mark user message as failed:
setMessages(prev =>
prev.map(m =>
m.id === userMessage.id
? { ...m, failed: true }
: m
)
);
// UI shows retry button on failed messages
Summary
In this lesson, we explored frontend-backend communication:
Key Takeaways:
-
Server vs. Client distinction is crucial: RAG operations must be server-side
-
Streaming provides better UX: Users see responses immediately
-
State management keeps it simple: Messages array + loading flag covers most cases
-
Loading states prevent confusion: Always show what's happening
-
Error handling is essential: Graceful degradation maintains trust
-
Accessibility matters: Screen readers and keyboard navigation should work
Next Steps
We have a working chat interface, but we need to secure it. In the next lesson, we'll explore Security and Row-Level Access—protecting your API endpoints and implementing fine-grained access control with Supabase RLS.
"The best interface is one you don't notice—it just works." — Unknown

