Streaming AI Chatbox with React and Gemini
Build a real-time AI chat interface with streaming responses and markdown rendering.
Introduction
30 Second Summary
Every time you use ChatGPT, you see text streaming onto the screen word by word. That real-time typing effect is not magic. It is a frontend engineering pattern you can build yourself.
In this project, you will build a streaming AI chatbox powered by Google Gemini. You will create a React frontend, an Express backend, and wire them together so AI responses flow in token by token.
What You'll Build
You type a question, press Send, and watch the AI's response stream onto the screen word by word with a typing indicator, syntax-highlighted code blocks, and clean markdown formatting.
By the end of this project, you'll have:
- A real-time streaming chat where you send a message and watch the AI respond word by word with a smooth typing indicator, just like ChatGPT.
- Syntax-highlighted code blocks and polished Markdown formatting that make AI-generated responses look clean and professional.
- A secure backend proxy that keeps your API key hidden from the browser and handles the streaming pipeline between your app and the AI.
- Secret Mission: Add a dark/light theme toggle with localStorage persistence and a New Chat button that resets the conversation.
Are there any prerequisites?
This project assumes basic HTML, CSS, and JavaScript knowledge. Some familiarity with React concepts like components, state, and props will help you follow along.
You should also have Node.js installed on your computer. Step 1 walks you through verifying your setup.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project
Every chat app has two halves: a frontend the user types into, and a backend that talks to the AI. Before you can build any chat features, you need both halves running side by side.
In this step, you will scaffold a React frontend with Vite, create an Express backend server, and grab a free API key from Google AI Studio. By the end, both servers will be running and ready for the chat code you will add in the next steps.
In this step, get ready to:
- Scaffold a React frontend with Vite and install its dependencies.
- Create an Express backend project with its own dependencies.
- Get a free Gemini API key and save it in a .env file.
Scaffold the React frontend with Vite
Vite is a modern build tool that scaffolds a ready-to-run React project in seconds. You will use it to create the frontend folder and install everything React needs.
- Press the Windows key to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
- Check your Node.js version by running:
node --version
You should see a version number like v22.x.x. Vite requires Node.js 20.19+ or 22.12+.
See a version lower than 20.19?
Visit the official Node.js downloads page at https://nodejs.org and install the latest LTS version. After installing, close and reopen VS Code, then run node --version again.
Help me upgrade Node.js on Windows
- Navigate to your Desktop by running this command:
cd ~/Desktop
- Scaffold a new React project by running this command:
npm create vite@latest ai-chatbox -- --template react
What does this command do?
- npm create vite@latest downloads and runs the latest Vite project scaffolder.
- ai-chatbox is the name of the folder it creates on your Desktop.
- -- --template react tells Vite to use the React starter template. The double dash (--) is required for npm 7+ to pass the flag through to Vite.
You should see output ending with instructions to cd into the folder and run npm install.
- Open the new project folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the ai-chatbox folder on your Desktop, then click Select Folder.
VS Code reloads with ai-chatbox as the workspace root. All future terminal commands will run from here.
- Open the integrated terminal again with Ctrl+`.
- Install the project dependencies by running:
npm install
You should see output listing the installed packages with no errors.
- Start the Vite development server by running:
npm run dev
What should I see?
Your terminal shows a local URL, typically http://localhost:5173/. This means the Vite dev server is running. It will stay running in this terminal, so leave it open.
- Open your browser and go to http://localhost:5173.
You should see the Vite + React starter page with the Vite and React logos and a counter button.
Don't see the starter page?
- Make sure the terminal still shows the Vite dev server running. If you accidentally closed it, run npm run dev again.
- Check that you are visiting http://localhost:5173 (not https).
Help me troubleshoot the Vite dev server
Create the Express backend
Your chat frontend needs a backend server to talk to the Gemini API. You will create a separate server folder inside your project for the Express backend.
- Open a second terminal tab by pressing Ctrl+Shift+`. This keeps the Vite dev server running in the first tab.
- Create the server folder by running:
mkdir server
- Confirm the folder was created by running:
ls
You should see server listed alongside src, public, node_modules, and the other Vite-generated files.
- In the VS Code Explorer sidebar (left side), expand the server folder.
- Right-click the server folder and click New File.
- Type package.json and press Enter.
- Paste the following code into server/package.json:
{
"name": "ai-chat-server",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@google/genai": "2.19.0",
"cors": "2.8.6",
"express": "5.1.0"
}
}
What does this file do?
- "type": "module" enables ES module syntax (import/export) instead of the older require() style.
- express is the web framework that handles HTTP requests. cors allows the React frontend to talk to the backend from a different port.
- @google/genai is the official Google Gemini SDK you will use to send prompts and receive AI responses.
- Save package.json with Ctrl+S.
- Install the backend dependencies by running this in your second terminal:
cd server
npm install
You should see output confirming the packages were installed with no errors.
Seeing install errors?
- Make sure you saved package.json before running npm install. Unsaved files are invisible to npm.
- Confirm your terminal is inside the server folder. Run pwd and check the path ends in server.
Help me fix npm install errors
Now you need a minimal server file so Express can start up. You will add the Gemini integration in a later step.
- Right-click the server folder in the Explorer sidebar and click New File.
- Type index.js and press Enter.
- Paste the following code into server/index.js:
// Import Express and CORS middleware
import express from 'express';
import cors from 'cors';
// Create the Express app
const app = express();
app.use(cors());
app.use(express.json());
// Start the server on port 3001
const PORT = 3001;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- app.use(cors()) allows the React frontend (running on port 5173) to make requests to this server (running on port 3001). Without it, the browser would block those requests.
- app.use(express.json()) tells Express to parse incoming JSON request bodies. This is how the frontend will send chat messages.
- app.listen(PORT, ...) starts the server on port 3001 and prints a confirmation message.
- Save index.js with Ctrl+S.
Before you run the server, what do you think will happen? Will you see output in the terminal, or will it stay silent?
- Start the Express server by running:
node index.js
You should see Server running on http://localhost:3001 printed in the terminal. Your backend is now running alongside the Vite frontend.
Don't see the server message?
- Make sure your terminal is inside the server folder. If you see a "Cannot find module" error, you may be running the command from the wrong directory.
- Check that you saved index.js before running the command.
Help me debug the Express server
Get a free Gemini API key
The Gemini API is what powers the AI responses in your chatbox. Google offers a free tier with no billing required, so you can start building without entering a credit card.
- Open your browser and go to https://aistudio.google.com.
- Sign in with your Google account if prompted.
What is Google AI Studio?
Google AI Studio is the dashboard where you manage your Gemini API keys. For new users, it automatically creates a default project and API key after you accept the Terms of Service.
You can copy your key from the API keys page.
- Click Get API key in the left panel.
- If you already see a key listed, click the copy icon next to it. If not, click Create API key and follow the dialog to create one, then copy it.
Keep your API key private
Your API key is a secret credential. Never paste it directly into frontend code or commit it to Git. You will store it in a .env file that stays on your machine.
- Back in VS Code, right-click the server folder in the Explorer sidebar and click New File.
- Type .env and press Enter.
- Paste the following line into server/.env, replacing your_api_key_here with the key you just copied:
GEMINI_API_KEY=your_api_key_here
- Save .env with Ctrl+S.
Why a .env file?
A .env file stores environment variables outside your code. Your server reads GEMINI_API_KEY from this file at startup, keeping the key out of source code entirely.
Take a moment to confirm both servers are still running. You should have two terminal tabs in VS Code.
- Terminal 1: the Vite dev server, showing the http://localhost:5173/ URL.
- Terminal 2: the Express server, showing Server running on http://localhost:3001.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved and both servers are running.
ⓧ I'd like to double check the full code
Here is what server/package.json should look like:
{
"name": "ai-chat-server",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@google/genai": "2.19.0",
"cors": "2.8.6",
"express": "5.1.0"
}
}
Here is what server/.env should look like (with your own key):
GEMINI_API_KEY=your_api_key_here
Your environment is ready. You have a React frontend serving a starter page, an Express backend listening for requests, and a Gemini API key stored safely on your machine. Next up, you will build the chat UI that turns this blank canvas into a real conversation interface.
Build the Chat UI
Your React frontend and Express backend are both running. The browser still shows Vite's default starter page at http://localhost:5173.
Before connecting to Gemini, you need a working chat interface. Building the UI first lets you see the layout take shape and verify that messages flow correctly without any API complexity.
In this step, get ready to:
- Create the App component with message state and a send form.
- Build a ChatMessage component to display user and assistant messages.
- Style everything with dark-mode CSS.
Create the App component with message state and a send form
The Vite scaffold created a default src/App.jsx with placeholder content. You will replace it with a chat component that tracks messages in state, handles form submission, and auto-scrolls to the latest message.
- Switch to src/App.jsx in VS Code.
- Select all the existing code and delete it.
- Paste in this code, which sets up the component's state and message-handling logic:
import { useState, useRef, useEffect } from 'react';
import ChatMessage from './components/ChatMessage';
import './App.css';
function App() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef(null);
// Scroll to the newest message whenever messages update
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// Handle form submission
const sendMessage = async (e) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const userMessage = { role: 'user', content: input.trim() };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput('');
setIsLoading(true);
// Echo placeholder - real AI comes in the next step
const assistantMessage = { role: 'assistant', content: 'Echo: ' + input.trim() };
setMessages([...updatedMessages, assistantMessage]);
setIsLoading(false);
};
What does this code do?
Four pieces of state power the chat. messages holds the full conversation array. input tracks what the learner is typing. isLoading prevents double-sends while a response is in progress. messagesEndRef is a ref attached to an invisible element at the bottom of the chat so the view auto-scrolls.
The sendMessage function creates a user message object, appends it to the array, clears the input, and immediately adds an echo response. In the next step, that echo will be replaced with a real Gemini call.
- The file is not finished yet. Directly below the closing }; of sendMessage, paste this JSX return block and export:
return (
<div className="app">
<header className="header">
<h1>AI Chat</h1>
</header>
<main className="chat-container">
{messages.length === 0 && (
<div className="empty-state">
<p>Send a message to start chatting</p>
</div>
)}
{messages.map((msg, index) => (
<ChatMessage key={index} message={msg} isLoading={false} />
))}
<div ref={messagesEndRef} />
</main>
<form className="input-container" onSubmit={sendMessage}>
<input type="text" value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..." disabled={isLoading}
/>
<button type="submit" disabled={isLoading || !input.trim()}>Send</button>
</form>
</div>
);
}
export default App;
What does the JSX render?
The layout has three sections stacked vertically. A fixed header displays the app title. A scrollable chat-container renders either an empty-state prompt or the list of ChatMessage components. A fixed form at the bottom holds the text input and Send button.
The invisible div with ref={messagesEndRef} sits at the very bottom of the message list. Each time messages updates, scrollToBottom scrolls that element into view so the newest message is always visible.
- Save src/App.jsx.
- Check your browser. You will see an error because ChatMessage does not exist yet. That is expected.
Seeing a syntax error instead?
Make sure the JSX return block sits directly below the closing }; of sendMessage and is still inside the App function body. The export default App; line must be outside and below the closing } of the function.
help me fix a syntax error in my App.jsx
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/App.jsx before moving on.
ⓧ I'd like to double check the full code
Your src/App.jsx should contain these sections in order:
- Three imports at the top: useState, useRef, useEffect from React, the ChatMessage component, and App.css.
- Four state declarations inside function App(): messages, input, isLoading, and messagesEndRef.
- A scrollToBottom function and a useEffect that calls it whenever messages changes.
- A sendMessage handler that creates a user message, clears the input, and appends an echo response.
- A JSX return with a header, chat-container main area, and an input-container form.
- An export default App; at the very bottom, outside the function.
Create a ChatMessage component that renders user and assistant messages differently
Each message in the chat needs its own component. ChatMessage decides whether to show a user bubble or an assistant bubble based on the message's role property.
- In VS Code's Explorer sidebar, right-click the src folder.
- Click New File.
- Type components/ChatMessage.jsx and press Enter. VS Code creates the components folder automatically.
- Paste this code into the new file:
// Renders a single chat message with different styles per role
function ChatMessage({ message, isLoading }) {
const isUser = message.role === 'user';
return (
<div className={`message ${isUser ? 'user-message' : 'assistant-message'}`}>
{/* Circular avatar showing "You" or "AI" */}
<div className="message-avatar">{isUser ? 'You' : 'AI'}</div>
<div className="message-content">
{isLoading ? (
// Animated dots while waiting for a response
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
) : (
<p>{message.content}</p>
)}
</div>
</div>
);
}
export default ChatMessage;
What does this component do?
The isUser flag drives everything. It picks the CSS class (user-message or assistant-message) and the avatar label ("You" or "AI"). When isLoading is true, the component shows an animated typing indicator instead of message text.
- Save src/components/ChatMessage.jsx.
- Check your browser at http://localhost:5173. The error is gone. You should see a mostly unstyled page with the text "Send a message to start chatting" and an input field.
- Type Hello in the input field and click Send. Your message appears, followed by an "Echo: Hello" response.
Still seeing an error in the browser?
Confirm that the file path is exactly src/components/ChatMessage.jsx. The import in App.jsx looks for ./components/ChatMessage. A mismatched folder name or file extension causes the module-not-found error.
help me fix a module not found error for ChatMessage
✔️ Awesome, I've got everything!
Great. Make sure src/components/ChatMessage.jsx is saved.
ⓧ I'd like to double check the full code
Your src/components/ChatMessage.jsx should match this exactly:
function ChatMessage({ message, isLoading }) {
const isUser = message.role === 'user';
return (
<div className={`message ${isUser ? 'user-message' : 'assistant-message'}`}>
<div className="message-avatar">{isUser ? 'You' : 'AI'}</div>
<div className="message-content">
{isLoading ? (
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
) : (
<p>{message.content}</p>
)}
</div>
</div>
);
}
export default ChatMessage;
Add the dark-mode CSS with chat bubbles, a fixed input bar, and auto-scroll
The echo works, but the page is plain white with default browser styles. You will replace the Vite default CSS with a dark theme that gives the chat a polished look.
- Switch to src/App.css in VS Code.
- Select all the existing code and delete it.
- Paste in these layout foundation styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #1a1a2e;
color: #e0e0e0;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.header {
background-color: #16213e;
padding: 16px 24px;
border-bottom: 1px solid #0f3460;
display: flex;
align-items: center;
justify-content: center;
}
.header h1 {
font-size: 1.2rem;
color: #e94560;
font-weight: 600;
}
What does this CSS do?
The reset removes default browser margins and padding. The dark background (#1a1a2e) and light text (#e0e0e0) create the dark theme. The .app container fills the full viewport height with a flex column layout, and the header sits at the top with a subtle border.
- Save src/App.css and check your browser.
- The page background is now dark navy. The header shows "AI Chat" in red-pink text, centered at the top.
- Add the chat area and message layout styles below your existing CSS in App.css:
.chat-container {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
font-size: 1.1rem;
}
.message {
display: flex;
gap: 12px;
max-width: 800px;
width: 100%;
margin: 0 auto;
}
What does this CSS do?
The .chat-container takes up all available space between the header and input bar, with vertical scrolling enabled. The .empty-state centers the placeholder text. Each .message row is capped at 800 pixels wide and centered horizontally.
- Save src/App.css and check your browser.
- The "Send a message to start chatting" text is now centered in the dark chat area.
- Add the avatar and message content styles below your existing CSS:
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
.user-message .message-avatar {
background-color: #0f3460;
color: #e0e0e0;
}
.assistant-message .message-avatar {
background-color: #e94560;
color: #fff;
}
.message-content {
flex: 1;
line-height: 1.6;
overflow-x: auto;
}
.message-content p {
margin-bottom: 8px;
}
.message-content p:last-child {
margin-bottom: 0;
}
What does this CSS do?
Each message gets a circular avatar badge. User avatars are dark blue. Assistant avatars are red-pink. The .message-content area fills the remaining width with comfortable line spacing.
- Add the typing indicator animation styles below your existing CSS:
.typing-indicator {
display: flex;
gap: 4px;
padding: 8px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
background-color: #6b7280;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) {
animation-delay: -0.32s;
}
.typing-indicator span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
What does this CSS do?
Three small dots bounce in sequence to show the AI is "thinking." The staggered animation-delay values make each dot bounce slightly after the previous one. You will not see this animation yet. It activates in a later step when the streaming response starts.
- Save src/App.css and send a test message in the browser.
- Your messages now have colored circular avatars. "You" appears in a blue circle for your messages, and "AI" appears in a red-pink circle for the echo response.
- Add the input container and text field styles below your existing CSS:
.input-container {
padding: 16px 24px;
background-color: #16213e;
border-top: 1px solid #0f3460;
display: flex;
gap: 12px;
max-width: 848px;
width: 100%;
margin: 0 auto;
}
.input-container input {
flex: 1;
padding: 12px 16px;
border: 1px solid #0f3460;
border-radius: 8px;
background-color: #1a1a2e;
color: #e0e0e0;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.input-container input:focus {
border-color: #e94560;
}
.input-container input::placeholder {
color: #6b7280;
}
What does this CSS do?
The input container sticks to the bottom of the screen with the same dark header color. The text field fills the available width with rounded corners and a subtle border. When you click into it, the border glows red-pink to show focus.
- Add the Send button styles as the final block of CSS:
.input-container button {
padding: 12px 24px;
background-color: #e94560;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.input-container button:hover:not(:disabled) {
background-color: #c73e54;
}
.input-container button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
What does this CSS do?
The Send button gets a bold red-pink background that darkens on hover. When the button is disabled (during loading or with an empty input), it fades to half opacity and changes the cursor to signal it is not clickable.
- Save src/App.css.
Styles look broken or partially applied?
Make sure you deleted all of the Vite default CSS before pasting. Leftover default styles can conflict with the new ones. Select all in App.css and confirm only your new styles are present.
Also check that each CSS block was pasted below the previous one, not inside an existing rule. Every opening { needs a matching closing }.
help me fix broken CSS styles in my chat app
✔️ Awesome, I've got everything!
Great. Confirm you have saved src/App.css and that the dark theme is fully visible in the browser.
ⓧ I'd like to double check the full code
Your src/App.css should contain these CSS sections in order. Scroll through your file and confirm each one is present:
- The * reset, body colors, .app flex layout, and .header with .header h1 styling.
- The .chat-container, .empty-state, and .message layout rules.
- The .message-avatar base styles plus .user-message and .assistant-message color variants. Then .message-content and its p rules.
- The .typing-indicator styles, span animations, nth-child delays, and the @keyframes bounce block.
- The .input-container layout, input field styles with :focus and ::placeholder rules.
- The button styles with :hover:not(:disabled) and :disabled states.
Before you test the finished UI, what do you think will happen when you type a message and press Send? Think about what you saw before the CSS was applied and what should be different now.
- Type Hello, AI! in the input field and press Send.
Your message appears on the left with a blue "You" avatar. An "Echo: Hello, AI!" response appears below it with a red-pink "AI" avatar. The input field clears. The dark theme fills the entire screen with the header fixed at the top and the input bar fixed at the bottom.
The chat interface is fully functional with an echo placeholder. Next up, you will connect this frontend to the Express backend and Gemini so the AI generates real responses instead of echoing your input.
Connect to Gemini
Your chat UI works. Messages land in dark-themed bubbles, the input clears after sending, and the chat auto-scrolls to the latest message. The one thing missing is a real AI on the other end.
Right now the chatbox echoes your own words back to you. In this step, you'll wire the Express backend to the Gemini API and connect the frontend to it. The echo bot becomes a real AI assistant.
In this step, get ready to:
- Create a /api/chat POST endpoint that sends conversation history to Gemini and returns the AI response.
- Update the frontend to call the backend instead of echoing messages.
Create the backend chat endpoint
The backend needs to accept the conversation from the frontend, translate it into the format Gemini expects, and return the AI's response. You'll build this in server/index.js.
- Switch to the terminal where your Express server is running.
- Stop the server by pressing Ctrl+C.
- Open server/index.js in VS Code and replace all of its content with this server skeleton:
import express from 'express';
import cors from 'cors';
import { GoogleGenAI } from '@google/genai';
// Create the Express app and enable middleware
const app = express();
app.use(cors());
app.use(express.json());
// Initialize the Gemini AI client with your API key
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const PORT = 3001;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
This sets up the bones of your server before any routes are added.
- cors() allows the React frontend (running on port 5173) to make requests to the backend (running on port 3001). Without it, the browser blocks cross-origin requests.
- express.json() parses incoming JSON request bodies so you can read req.body as a JavaScript object.
- GoogleGenAI is the official SDK client. It reads your API key from the .env file you created earlier.
- Save server/index.js.
- In the server terminal, make sure you are inside the server folder, then start the server by running:
node --env-file=.env index.js
You should see Server running on http://localhost:3001 in the terminal.
Server not starting?
- If you see Cannot find module, make sure you ran npm install inside the server folder in Step 1.
- If you see a syntax error, double-check that your server/package.json has "type": "module" so Node.js recognizes import statements.
- help me debug my server startup error
The server starts, but it has no routes yet. Time to add the chat endpoint.
- In server/index.js, find the line that says const PORT = 3001;.
- Add the following route handler directly above that line:
app.post('/api/chat', async (req, res) => {
const { messages } = req.body;
// Validate that messages were sent
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ error: 'Messages array is required' });
}
try {
// Convert frontend message format to Gemini's expected format
const contents = messages.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}));
// Send the conversation to Gemini and get the full response
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: contents,
});
res.json({ text: response.text });
} catch (error) {
console.error('Gemini API error:', error.message);
res.status(500).json({ error: error.message });
}
});
What does this endpoint do?
This route receives the full conversation history from the frontend and forwards it to Gemini.
- The messages.map() call converts the frontend format (role: 'assistant') into Gemini's format (role: 'model'). Gemini uses 'model' instead of 'assistant' for AI responses.
- ai.models.generateContent() sends the entire conversation to gemini-2.5-flash and waits for the complete response before returning.
- response.text is a convenience getter on the SDK response that extracts the text content from Gemini's reply.
- Save server/index.js.
- Stop the running server with Ctrl+C and restart it by running:
node --env-file=.env index.js
You should see Server running on http://localhost:3001 again. The server now has a working /api/chat route ready to accept messages.
Seeing an error on restart?
- Make sure the route handler code is placed above const PORT = 3001;, not below the app.listen() call.
- Check that server/.env exists and contains your Gemini API key on the first line.
- help me fix my server/index.js error
✔️ Awesome, I've got everything!
Great. Double check you have saved server/index.js and your server is running.
ⓧ I'd like to double check the full code
Your server/index.js should have these sections in order. Scroll up and confirm each one is present:
- Three import statements at the top (express, cors, GoogleGenAI).
- App setup: const app = express(), app.use(cors()), app.use(express.json()).
- AI client: const ai = new GoogleGenAI(...).
- The app.post('/api/chat', ...) route handler with validation, message mapping, generateContent call, and error handling.
- Server listen: const PORT = 3001 and app.listen().
Update the frontend to call the backend
The backend is ready. Now the frontend needs to stop echoing and start sending messages to your new endpoint instead.
- Open src/App.jsx in VS Code.
- Inside the sendMessage function, find these lines:
// Echo placeholder
const assistantMessage = { role: 'assistant', content: 'Echo: ' + input.trim() };
setMessages([...updatedMessages, assistantMessage]);
setIsLoading(false);
- Delete those four lines and replace them with this code:
try {
// Send the conversation to the backend
const response = await fetch('http://localhost:3001/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: updatedMessages }),
});
const data = await response.json();
// Display the AI response in the chat
const assistantMessage = { role: 'assistant', content: data.text || 'Sorry, something went wrong.' };
setMessages([...updatedMessages, assistantMessage]);
} catch (error) {
const assistantMessage = { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' };
setMessages([...updatedMessages, assistantMessage]);
} finally {
setIsLoading(false);
}
What does this code do?
- fetch() sends a POST request to your Express backend with the full conversation history as JSON.
- The try/catch block handles both success and failure. If the backend is down or the API key is invalid, the user sees a friendly error message instead of a crash.
- setIsLoading(false) runs in the finally block, so it always resets regardless of success or failure.
There is one more small change. The ChatMessage component already supports a typing indicator, but it is currently hardcoded to false. You need to connect it to the real loading state.
- Scroll down to the JSX return and find this line inside the messages.map():
isLoading={false}
- Replace it with:
isLoading={isLoading && index === messages.length - 1 && msg.role === 'assistant' && !msg.content}
Why this condition?
The typing indicator should only show on the very last message, only when it is an assistant message, and only while the content is still empty. This combination prevents the indicator from flickering on older messages or on the user's own messages.
- Save src/App.jsx. Vite auto-refreshes the browser for you.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/App.jsx and your browser has refreshed.
ⓧ I'd like to double check the full code
Your src/App.jsx should have these sections in order. Scroll up and confirm each one is present:
- Imports at the top: useState, useRef, useEffect from React, plus ChatMessage and App.css.
- State hooks: messages, input, isLoading, messagesEndRef.
- scrollToBottom function and its useEffect.
- sendMessage function with the fetch() call to http://localhost:3001/api/chat inside a try/catch/finally.
- JSX return with isLoading={isLoading && index === ...} on ChatMessage.
Before you test, think about what you expect to happen. You type a question and press Send. Do you think the AI response will appear instantly, or will there be a delay?
- Return to your browser at http://localhost:5173.
- Type What is JavaScript? in the message input.
- Click Send.
After a few seconds of silence, the full AI response drops into the chat all at once. It works, but the experience feels sluggish. You press Send, nothing happens for two or three seconds, and then a wall of text appears instantly.
Why does the response feel so slow?
The generateContent method waits for Gemini to finish generating the entire response before returning anything. The longer the answer, the longer you stare at a blank chat.
Real chat apps like ChatGPT stream responses token by token so the user sees progress immediately. That is exactly the upgrade you will build in the next step.
Not seeing an AI response?
- Open your browser's Developer Tools (F12) and check the Console tab for errors. A network error means the Express server is not running at http://localhost:3001.
- If you see a 500 error, check the server terminal for the Gemini API error message. The most common cause is a missing or invalid API key in server/.env.
- Make sure your .env file has no quotes around the key value. It should be GEMINI_API_KEY=AIza..., not GEMINI_API_KEY="AIza...".
- help me debug my Gemini chat connection
Your chatbox is now powered by real AI. The responses are accurate, but that awkward delay and sudden wall of text is a long way from the smooth ChatGPT experience. Next up, you'll replace the all-at-once response with real-time streaming that flows word by word.
Add Streaming Responses
Your chatbox connects to Gemini and returns real AI answers. But you've probably noticed a problem: every response arrives as one big block after a multi-second delay.
Real chat apps stream responses token by token so you see words appearing as the AI generates them. In this step, you'll convert both the backend and frontend to use Server-Sent Events (SSE) streaming. This is the core upgrade that makes your chat feel alive.
In this step, get ready to:
- Convert the backend endpoint to stream responses using Server-Sent Events.
- Update the frontend to read the stream and display text as it arrives.
- See the typing indicator in action while waiting for the first token.
Convert the backend endpoint to SSE streaming
Right now the backend calls generateContent, which waits for the entire AI response before sending anything back. To stream instead, you'll switch to generateContentStream and send each chunk as a Server-Sent Event.
- In server/index.js, find the app.post('/api/chat', ...) route handler.
- Find the try { block inside it. It currently starts like this:
try {
const contents = messages.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}));
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: contents,
});
res.json({ text: response.text });
} catch (error) {
console.error('Gemini API error:', error.message);
res.status(500).json({ error: error.message });
}
- Delete everything from the try { line down through the closing } of the catch block.
- Replace it with this code, which adds SSE headers above the try block and streams the Gemini response:
// Set SSE response headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
const contents = messages.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}));
// Stream the response instead of waiting for the full result
const response = await ai.models.generateContentStream({
model: 'gemini-2.5-flash',
contents: contents,
});
// Write each chunk as an SSE event
for await (const chunk of response) {
const text = chunk.text;
if (text) {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
}
}
// Signal the stream is complete
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
console.error('Gemini API error:', error.message);
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
}
What does this code do?
- The three res.setHeader lines go before the try block. They tell the browser this is a streaming SSE response, not a regular JSON reply.
- generateContentStream returns an async iterable instead of waiting for the full response. Each chunk has a .text property with the latest piece of generated text.
- The for await loop writes each chunk as an SSE event in the format data: {"text": "..."} followed by two newlines (the SSE protocol delimiter).
- When the stream finishes, data: [DONE] signals the frontend to stop reading.
- Save server/index.js.
- In the terminal running your Express server, press Ctrl+C to stop it.
- Restart the server by running:
node --env-file=.env index.js
You should see Server running on http://localhost:3001 in the terminal.
Server not starting?
- Check that you kept the const contents = messages.map(...) block inside the try. The mapping from app roles to Gemini roles is still needed.
- Make sure you deleted the old res.json({ text: response.text }) line. Calling res.json() after setting SSE headers causes an error.
- Confirm you're running the command from inside the server folder.
Help me debug my Express server SSE streaming setup.
Update the frontend to read the SSE stream
The frontend currently calls response.json() to read the entire response at once. Since the backend now sends a stream of events, you need a ReadableStream reader to consume them as they arrive.
You'll also prepare the stylesheet for formatted AI content like code blocks and lists.
- In src/App.css, find the .message-content p:last-child rule. Add these styles directly below it:
/* Style code blocks in AI responses */
.message-content pre {
margin: 12px 0;
border-radius: 8px;
overflow-x: auto;
}
/* Style inline code snippets */
.message-content code {
background-color: #2d2d44;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Reset code styles inside pre blocks */
.message-content pre code {
background: none;
padding: 0;
}
/* Style lists in AI responses */
.message-content ul,
.message-content ol {
margin: 8px 0;
padding-left: 24px;
}
.message-content li {
margin-bottom: 4px;
}
Why add these styles now?
AI responses often contain code snippets, lists, and formatted text. These CSS rules prepare the layout for that content. You'll see them take full effect in the next step when you add a markdown renderer.
- Save src/App.css.
- Now switch to src/App.jsx. Find the sendMessage function.
- Find this line inside sendMessage:
setIsLoading(true);
- Delete everything below that line, down to (but not including) the closing }; of the sendMessage function. This removes the old try, catch, and finally blocks.
- Below setIsLoading(true);, paste this code to create an empty assistant message and set up the streaming connection:
// Add an empty message so the typing indicator shows immediately
const assistantMessage = { role: 'assistant', content: '' };
setMessages([...updatedMessages, assistantMessage]);
try {
const response = await fetch('http://localhost:3001/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: updatedMessages }),
});
// Set up a stream reader to consume chunks as they arrive
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
- Directly below that code (still inside the try block), paste the stream reading loop:
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
if (parsed.text) {
// Append chunk text and update the message
fullText += parsed.text;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: fullText,
};
return updated;
});
}
} catch {
// skip malformed chunks
}
}
}
}
What does this streaming code do?
- The empty assistant message (content: '') is created before the try block. This triggers the typing indicator immediately because isLoading is true and the last message has no content.
- response.body.getReader() gives you a ReadableStream reader that consumes the SSE events as raw bytes, decoded into text by TextDecoder.
- Each SSE event arrives as data: {"text": "..."}. The loop parses it, appends the text to fullText, and uses setMessages with a functional update to replace the last message's content.
- The functional update (prev) => ... is critical here. It reads the latest state each time, preventing lost updates when chunks arrive faster than React re-renders.
- Now add the closing catch and finally blocks below the while loop you just pasted:
} catch (error) {
// Update the existing empty message with the error text
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: 'Sorry, something went wrong. Please try again.',
};
return updated;
});
} finally {
setIsLoading(false);
}
Why change the catch block?
The old catch created a new assistant message. But the streaming version already adds an empty assistant message before the try. If the catch also created a new one, you'd see a duplicate. The functional setMessages update replaces the existing empty message with the error text instead.
- Save src/App.jsx.
Before you test, think about this: when you send a message now, do you think you'll see the response appear all at once, or word by word?
- Switch to your browser at http://localhost:5173.
- Type "What is JavaScript?" and press Send.
The response streams in word by word. You can see the text growing on screen in real time, just like ChatGPT.
Response still appearing all at once?
- Make sure you restarted the Express server after editing server/index.js. The backend must serve SSE for the frontend to stream.
- Check that you replaced generateContent with generateContentStream in the server. The old method returns everything at once.
- Verify you deleted the old const data = await response.json() line in App.jsx. If it's still there, the frontend reads the response as JSON instead of a stream.
Help me debug my SSE streaming connection.
See the typing indicator in action
You built the typing indicator component back in Step 2, but it never actually appeared during Step 3. Now it does.
- Send another message, something longer like "Explain the difference between let, const, and var in JavaScript with examples."
- Watch closely right after you press Send.
You'll see three bouncing dots appear for a brief moment. Then text starts flowing in and the dots disappear.
How does the typing indicator work?
The ChatMessage component checks two conditions: isLoading is true, and the last assistant message has empty content. When you create the empty assistant message before the try block, both conditions are met and the bouncing dots render.
As soon as the first chunk of text arrives and fullText gets its first character, the !msg.content check fails and the dots switch to the actual text. The transition is seamless.
✔️ Awesome, I've got everything!
Great. Double check you've saved both server/index.js and src/App.jsx.
ⓧ I'd like to double check the full code
Since the files are long, confirm each section is in place by scrolling through your code:
- server/index.js: The route handler should start with three res.setHeader calls (text/event-stream, no-cache, keep-alive). Inside the try, you should see generateContentStream (not generateContent), a for await loop calling res.write, and data: [DONE] at the end.
- src/App.jsx: The sendMessage function should create an empty assistant message (content: '') before the try. Inside the try, you should see response.body.getReader(), a while (true) loop, and setMessages using the (prev) => ... pattern. The catch block should also use the functional setMessages update.
- src/App.css: Below .message-content p:last-child, confirm you have rules for .message-content pre, .message-content code, .message-content pre code, and .message-content ul, .message-content ol.
Your chat now streams responses in real time with a smooth typing indicator. In the next step, you'll make those responses look even better by rendering markdown formatting and syntax-highlighted code blocks.
Render Markdown and Code Blocks
Your chatbox streams AI responses token by token. Every message flows onto the screen in real time, just like a professional chat app.
But there is a problem hiding in those responses. AI models return text with Markdown formatting, including code blocks wrapped in triple backticks, bullet lists, and bold text. Your app currently dumps all of that as plain text, so the raw formatting characters show up on screen instead of proper formatting.
In this step, get ready to:
- Install markdown rendering and syntax highlighting packages.
- Update the ChatMessage component to render formatted markdown with syntax-highlighted code blocks.
Install the markdown rendering packages
First, see the problem for yourself. Your ChatMessage component currently wraps every message in a <p> tag, which works for plain text but ignores all markdown formatting.
- Switch back to your chat app in the browser at http://localhost:5173.
- Type Write a hello world program in Python with comments and press Send.
You will see raw triple backticks, pound signs, and unformatted code all mashed together as plain text. The response is readable, but it looks nothing like a real code block.
Three packages will fix this. You need to install them in your frontend project.
- Stop your frontend dev server by pressing Ctrl+C in the terminal where Vite is running.
- Install the three packages by running this command:
npm install react-markdown remark-gfm react-syntax-highlighter
What do these packages do?
- react-markdown parses markdown text and renders it as React components instead of raw strings.
- remark-gfm adds support for GitHub Flavored Markdown features like tables, strikethrough, and task lists.
- react-syntax-highlighter renders code blocks with language-specific color highlighting, the same way code editors do.
- Once the install finishes, restart your frontend dev server by running:
npm run dev
- Confirm the Vite dev server starts and shows the local URL in the terminal output.
Install failed?
- Make sure you are in the frontend project folder, not the server folder. Run ls and check that you see a src directory listed.
- If you see a permissions error, try running the command again in a new terminal window.
Why can't I install these packages?
Render assistant messages as formatted markdown
With the packages installed, you can update the ChatMessage component to use them. The plan: user messages stay as plain text, but assistant messages get rendered through ReactMarkdown with syntax highlighting for code blocks.
- In ChatMessage.jsx, add these four imports at the very top of the file, above the function ChatMessage line:
// Markdown rendering and syntax highlighting
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
What are these imports?
- ReactMarkdown is the component that parses markdown strings and renders them as proper HTML elements.
- remarkGfm is a plugin that adds GitHub Flavored Markdown support (tables, strikethrough, task lists).
- SyntaxHighlighter uses the Prism engine to render code with color-coded syntax highlighting.
- oneDark is a dark color theme that matches your app's design.
Now you need to change how assistant messages render. Currently, every message uses a plain <p> tag. You will split the rendering so user messages stay as plain text, but assistant messages pass through ReactMarkdown.
- In ChatMessage.jsx, find this code inside the return statement:
) : (
<p>{message.content}</p>
)}
- Replace that section with this code, which splits rendering between user and assistant messages:
) : isUser ? (
<p>{message.content}</p>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
// Check if this is a fenced code block with a language
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
// Inline code: render as a plain <code> tag
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{message.content}
</ReactMarkdown>
)}
What does this code do?
The ternary chain now has three branches. If the message is loading, show the typing indicator. If it is a user message, render plain text in a <p> tag. If it is an assistant message, render it through ReactMarkdown.
The components prop overrides how code blocks render. When ReactMarkdown encounters a fenced code block (like ```python), it passes a className like language-python. The regex extracts the language name and hands it to SyntaxHighlighter, which applies color-coded styling.
- Save ChatMessage.jsx.
Before you test, think about what you expect to see. If you ask the AI for Python code now, do you think the triple backticks will still show up as raw text, or will you see a formatted code block?
- Switch back to your browser at http://localhost:5173.
- Type Write a hello world program in Python with comments and press Send.
The AI response now streams in with full markdown formatting. Code blocks appear inside a dark-themed container with syntax highlighting. Python keywords, strings, and comments each have their own color. No more raw backticks.
Still seeing raw backticks?
- Make sure you saved ChatMessage.jsx after making both changes (imports and rendering logic).
- Check that the four import lines are at the very top of the file, before the function ChatMessage line.
- If you see a blank screen or console errors, verify that you restarted the dev server after installing the packages.
Why aren't my markdown code blocks rendering?
✔️ Awesome, I've got everything!
Great. Double check that you have saved ChatMessage.jsx.
ⓧ I'd like to double check the full code
The full file is built from two chunks you added above. Scroll up and confirm both are present in order:
- The four imports at the top of the file (ReactMarkdown, remarkGfm, SyntaxHighlighter, oneDark).
- The updated rendering logic with the isUser ? ... : <ReactMarkdown> ternary chain replacing the old <p>{message.content}</p> line.
The src/App.jsx and src/App.css files are unchanged from the previous step. No server-side changes were needed either.
Your chatbox is fully functional. Messages stream in real time with proper markdown formatting and syntax-highlighted code blocks. You have built a complete ChatGPT-style interface powered by Google Gemini. Now it is time for the Secret Mission, where you will push this project even further.
Secret mission
Theme Toggle and New Chat
Your chatbox works, but it is locked into dark mode with no way to start a fresh conversation. In this mission, you will add a dark/light theme toggle that remembers your preference across page reloads using localStorage, plus a New Chat button that clears the conversation instantly.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine. The Gemini API free tier has no billing attached, so there are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Your project folder on the Desktop (contains the Vite React app in src/ and the Express server in server/).
- A Gemini API key stored in server/.env and registered in Google AI Studio (free tier, no charges).
- A chat-theme entry saved in your browser's localStorage.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep chatting.
- Your project files stay on your Desktop.
- Your Gemini API key stays active in Google AI Studio on the free tier.
- To start chatting again, open two terminal windows. Run npm run dev in the frontend folder and node --env-file=.env index.js in the server/ folder.
Pause - I'll come back to this later
Stop the running dev servers to free up your terminal. Your files and API key stay in place for next time.
- Switch to each terminal window running a dev server and press Ctrl+C to stop it.
- Your project folder and .env file remain on your Desktop, ready to pick up later.
Delete - I don't want to use this again
Remove all project files and clean up completely.
- Stop any running dev servers by pressing Ctrl+C in each terminal window.
- Open Finder (macOS) or File Explorer (Windows) and navigate to your Desktop.
- Find the project folder that contains your src/ and server/ directories. Right-click it and select Move to Trash (macOS) or Delete (Windows).
- To revoke your Gemini API key, go to Google AI Studio and delete the key from your API keys list. This is optional since the free tier has no charges.
- To clear the theme preference from your browser, open the browser console with F12, click the Console tab, and run:
localStorage.removeItem('chat-theme')
This removes the chat-theme entry saved by the Secret Mission's theme toggle.
Nice Work!
Nice Work!
Nice work! You just built a fully functional streaming AI chatbox powered by Google Gemini with a polished React frontend and a secure Express backend.
You've learned how to:
- Built a React + Vite chat frontend with component-based state management, auto-scrolling, and a dark-mode UI, connected to an Express backend that securely proxies requests to the Gemini API.
- Implemented real-time token-by-token streaming using Server-Sent Events (SSE) and ReadableStream, and experienced firsthand the difference between non-streaming and streaming AI responses.
- Added professional markdown rendering with syntax-highlighted code blocks using react-markdown and react-syntax-highlighter.
- Secret Mission: Added a dark/light theme toggle with localStorage persistence and a New Chat button that resets the conversation.
Ready to quiz yourself?