Build an AI Chat Assistant with Gemini
Build a full-stack AI chat app with Express, streaming responses, and Gemini.
Introduction
30 Second Summary
Every time you use ChatGPT, Claude, or Gemini, you're interacting with an AI chat application built on the same architecture you're about to learn. The patterns behind these tools are surprisingly approachable.
In this project, you will build a full-stack AI chat assistant using Express and the Google Gemini API. You'll stream responses word-by-word, manage conversation history, and experiment with temperature and system prompts to control AI behavior.
What You'll Build
A production-style chat application with an Express backend that securely calls the Gemini API and a React frontend that displays streaming AI responses in real-time.
By the end of this project, you'll have:
- An Express server that securely connects to the Gemini API and streams responses using Server-Sent Events.
- A React frontend with a real-time chat interface that displays AI responses word-by-word as they generate.
- Interactive temperature and conversation history controls that teach you core Gen AI concepts through hands-on experimentation.
- Secret Mission: Add a personality switcher that lets users choose between different AI personas and see how system prompts dramatically shape AI behavior.
Are there any prerequisites?
This project assumes familiarity with JavaScript, Express.js, and basic React concepts. You should be comfortable creating routes, handling requests, and working with React state.
Before We Start
Before We Start...
Set Up the Project Structure
Every AI chat application has two halves: a backend that talks to the AI model, and a frontend that the user interacts with.
In this step, you will create the project folder structure, install your backend dependencies, grab a free Gemini API key, and scaffold a React frontend using Vite.
In this step, get ready to:
- Create the project folder structure and install backend dependencies.
- Get your free Gemini API key and verify it works.
- Scaffold the React frontend with Vite.
Create the project folders and install backend dependencies
Your project needs two separate directories. The server folder holds the Express backend that communicates with Gemini. The client folder holds the React frontend that the user sees.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ai-chat-assistant
cd ai-chat-assistant
- Create the server and client directories by running:
mkdir server client
- Confirm the folders exist by running:
ls
You should see server and client in the output.
Now you will set up the backend's package.json with the ESM module type and install the four packages this project needs.
- Move into the server folder by running:
cd server
- Create the package.json file by running:
npm init -y
This generates a default package.json in the server folder.
- Open the ai-chat-assistant folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the ai-chat-assistant folder, then click Open.
- Open server/package.json from the Explorer sidebar.
- Replace the entire contents with this code:
{
"name": "ai-chat-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@google/genai": "^2.10.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1"
}
}
What does this file do?
This package.json defines your backend project. The "type": "module" field enables ES module import/export syntax instead of require().
- @google/genai is Google's official SDK for calling the Gemini API.
- express is the web server framework that handles HTTP requests.
- cors allows your React frontend to make requests to the backend.
- dotenv loads your API key from a .env file into process.env.
- Save server/package.json.
- Install the dependencies by running this command in your terminal (make sure you are inside the server folder):
npm install
You should see output confirming the packages were installed. A node_modules folder and package-lock.json file will appear in the server directory.
Seeing errors during npm install?
- Make sure your terminal is inside the server folder. Run pwd (macOS) or cd (Windows) to check your current directory.
- Confirm your Node.js version is 20 or higher by running node --version. The @google/genai package requires Node.js 20+.
Why isn't npm install working for my AI chat project?
Get your free Gemini API key
Your backend needs an API key to authenticate with Google's Gemini model. Google AI Studio provides free API keys with no credit card required.
- Open Google AI Studio in your browser.
- Sign in with your Google account.
- Click Create API Key.
- Copy the generated API key to your clipboard.
Why is the API key free?
Gemini offers a free tier with no credit card required. You get ongoing access with rate limits that are generous for learning and prototyping. This project stays well within those limits.
- In VS Code, create a new file called .env inside the server folder. Right-click on the server folder in the Explorer sidebar and select New File.
- Name the file .env and press Enter.
- Paste this into the file, replacing your-api-key-here with the key you copied:
GEMINI_API_KEY=your-api-key-here
Why a .env file?
The .env file keeps your API key out of your source code. This means you can share your code without accidentally exposing your credentials. The dotenv package loads this file automatically when your server starts.
- Save server/.env.
Now verify that your API key works by running a quick test.
- Create a temporary test file by running this command from the server folder:
node -e "import 'dotenv/config'; import {GoogleGenAI} from '@google/genai'; const ai = new GoogleGenAI({apiKey: process.env.GEMINI_API_KEY}); const r = await ai.models.generateContent({model: 'gemini-2.5-flash', contents: 'Say hello in one sentence.'}); console.log(r.text);"
You should see a short greeting from Gemini printed in your terminal. This confirms your API key is working.
Not seeing a response?
- Double-check that you pasted your actual API key into server/.env (not the placeholder text your-api-key-here).
- Make sure you are running the command from inside the server folder where node_modules lives.
- If you see a permission or quota error, wait a minute and try again. New API keys can take a moment to activate.
Why isn't my Gemini API key working?
Scaffold the React frontend with Vite
The frontend is where your users will type messages and see AI responses stream in. You will use Vite to scaffold a React project instantly.
- Move back to the project root by running:
cd ..
- Scaffold the React frontend inside the client folder by running:
npm create vite@latest client -- --template react
What does this command do?
- npm create vite@latest runs Vite's project scaffolding tool.
- client tells it to put the files inside the existing client directory.
- -- --template react selects the React JavaScript template so you skip the interactive prompts.
- Move into the client folder and install the frontend dependencies by running:
cd client
npm install
You should see output confirming that React and Vite dependencies were installed successfully.
Vite command not found?
- Make sure you ran the command from the ai-chat-assistant root folder (one level above client).
- If you see a prompt asking to install create-vite, type y and press Enter to proceed.
Why isn't npm create vite working for my project?
Your project structure is ready. You now have a server folder with all backend dependencies installed, a verified Gemini API key, and a client folder with a fresh React app.
✔️ Awesome, I've got everything!
Great. Double check you have saved server/package.json and server/.env before moving on.
ⓧ I'd like to double check the full code
Here is what your server/package.json should look like:
{
"name": "ai-chat-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@google/genai": "^2.10.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1"
}
}
And your server/.env should look like this (with your real key replacing the placeholder):
GEMINI_API_KEY=your-api-key-here
Your development environment is locked in. Next up, you will build the Express backend that sends messages to Gemini and streams back AI responses.
Build the Express Backend with Gemini Integration
Your server dependencies are installed and your API key is ready. Now it is time to build the actual server that receives chat messages from your frontend and sends them to Gemini for a response.
The core challenge is connecting your Express server to the Gemini API in a way that streams text back to the client word-by-word. You will use Server-Sent Events (SSE) to accomplish this. This is the same technique ChatGPT uses to display responses as they generate.
In this step, get ready to:
- Set up an Express server with the Google GenAI SDK initialized.
- Create a streaming chat endpoint that formats conversation history for Gemini.
- Test system prompts and temperature settings to see how they change AI behavior.
Set up the Express server and initialize the Gemini SDK
Before you can handle any chat requests, you need a running Express server that knows how to talk to Gemini. This first chunk sets up the foundation: imports, middleware, and the SDK connection.
- Create a new file called index.js inside your server folder.
- Add the following code to set up your Express app and connect to Gemini:
import "dotenv/config";
import express from "express";
import cors from "cors";
import { GoogleGenAI } from "@google/genai";
// Create the Express app and set the port
const app = express();
const PORT = 3001;
// Enable cross-origin requests and JSON body parsing
app.use(cors());
app.use(express.json());
// Initialize the Gemini SDK with your API key
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
// Start the server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
This sets up a minimal Express server with two pieces of middleware. cors() allows your React frontend (running on a different port) to make requests to this server. express.json() parses incoming JSON request bodies so you can read them in your route handlers.
The GoogleGenAI class is initialized with your API key from the .env file. The import "dotenv/config" line at the top automatically loads your .env variables into process.env.
- Save server/index.js.
- In your terminal, make sure you are inside the server folder and start the server by running:
npm start
- You should see Server running on http://localhost:3001 printed in your terminal.
Not seeing the server start?
- Make sure you are running npm start from inside the server folder, not the project root.
- Check that your .env file exists inside the server folder with your GEMINI_API_KEY set.
- If you see a "Cannot find module" error, run npm install again inside the server folder.
Why won't my Express server start?
Stop the server with Ctrl+C in your terminal. You will restart it after adding the chat endpoint.
Create the chat endpoint with conversation history and streaming
The heart of your backend is a single POST endpoint that receives messages from the frontend, forwards them to Gemini with conversation history, and streams the AI response back using Server-Sent Events.
- In server/index.js, find the line that says // Start the server. Add the following route handler above it:
app.post("/api/chat", async (req, res) => {
const { messages, systemInstruction, temperature } = req.body;
// Validate that messages were provided
if (!messages || messages.length === 0) {
return res.status(400).json({ error: "Messages array is required" });
}
// Convert messages to Gemini's expected format
const contents = messages.map((msg) => ({
role: msg.role,
parts: [{ text: msg.text }],
}));
// Set SSE headers for streaming
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Track if the client disconnects mid-stream
let closed = false;
req.on("close", () => {
closed = true;
});
try {
// Call Gemini with streaming enabled
const response = await ai.models.generateContentStream({
model: "gemini-2.5-flash",
contents,
config: {
systemInstruction: systemInstruction || "You are a helpful assistant.",
temperature: temperature ?? 1.0,
},
});
// Send each chunk as an SSE event
for await (const chunk of response) {
if (closed) break;
const text = chunk.text;
if (text) {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
}
}
// Signal that streaming is complete
if (!closed) {
res.write(`data: [DONE]\n\n`);
res.end();
}
} catch (error) {
console.error("Gemini API error:", error.message);
if (!closed) {
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
}
}
});
What does this code do?
This endpoint handles the entire chat flow in four stages:
- Conversation history: The messages array from the client is mapped to Gemini's required format. Each message becomes an object with a role ("user" or "model") and parts (an array containing the text). This lets Gemini see the full conversation and respond in context.
- SSE headers: The three headers (Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive) tell the browser to keep the connection open and expect a stream of events.
- Streaming: generateContentStream() returns an async iterable. Each chunk contains a piece of the AI's response. You write each piece as a data: SSE event so the frontend can display text as it arrives.
- System prompt and temperature: systemInstruction gives the AI a personality (defaults to "You are a helpful assistant."). temperature controls creativity. A value of 0 makes responses focused and deterministic. A value of 2.0 makes them creative and unpredictable.
Why handle client disconnection?
If a user closes their browser tab mid-response, the server would keep generating text and writing to a dead connection. The req.on("close") listener sets a closed flag so the streaming loop exits early, saving API usage and preventing errors.
- Save server/index.js.
You won't see a visible change yet since the server has no frontend to call it. In the next substep, you will test the endpoint directly from your terminal.
✔️ Awesome, I've got everything!
Great. Double check you have saved server/index.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete server/index.js file at this point:
import "dotenv/config";
import express from "express";
import cors from "cors";
import { GoogleGenAI } from "@google/genai";
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
app.post("/api/chat", async (req, res) => {
const { messages, systemInstruction, temperature } = req.body;
if (!messages || messages.length === 0) {
return res.status(400).json({ error: "Messages array is required" });
}
const contents = messages.map((msg) => ({
role: msg.role,
parts: [{ text: msg.text }],
}));
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
let closed = false;
req.on("close", () => {
closed = true;
});
try {
const response = await ai.models.generateContentStream({
model: "gemini-2.5-flash",
contents,
config: {
systemInstruction: systemInstruction || "You are a helpful assistant.",
temperature: temperature ?? 1.0,
},
});
for await (const chunk of response) {
if (closed) break;
const text = chunk.text;
if (text) {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
}
}
if (!closed) {
res.write(`data: [DONE]\n\n`);
res.end();
}
} catch (error) {
console.error("Gemini API error:", error.message);
if (!closed) {
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
}
}
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Test system prompts and temperature with curl
Your endpoint is built. Now you can test it directly from the terminal using curl to see streaming responses arrive in real time.
- Start your server by running:
npm start
- Open a second terminal window (keep the server running in the first one).
- Send a test message to your endpoint by running:
curl -N -X POST http://localhost:3001/api/chat \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "text": "Hello! Tell me a fun fact."}]}'
You should see multiple data: lines stream into your terminal, each containing a JSON object with a text field. The final line will be data: [DONE].
What is the -N flag?
The -N flag disables output buffering so you see each SSE event the moment it arrives, rather than waiting for the entire response to finish.
- Now test with a high temperature value (2.0) to see more creative output:
curl -N -X POST http://localhost:3001/api/chat \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "text": "Tell me a joke"}], "temperature": 2.0}'
- Try a completely different personality by changing the system instruction:
curl -N -X POST http://localhost:3001/api/chat \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "text": "Hello!"}], "systemInstruction": "You are a pirate. Respond in pirate speak."}'
Notice how the AI's tone and vocabulary change completely based on the system instruction. This is the power of prompt engineering.
What is temperature?
Temperature controls randomness in the AI's word choices. At 0.0, the model always picks the most likely next word, making responses focused and predictable. At 2.0, it considers less likely words, producing surprising and creative (sometimes nonsensical) output.
The default of 1.0 is a balanced middle ground. Try the same prompt at 0.0 and 2.0 to see the difference firsthand.
Getting an error from the API?
- If you see an authentication error, double check that your .env file has the correct GEMINI_API_KEY value with no extra spaces or quotes.
- If the connection refuses, make sure your server is still running in the other terminal window on port 3001.
- If you see no output at all, check that you included the -N flag in your curl command to disable buffering.
Why am I getting an error when calling the Gemini API from my Express server?
Your backend is fully functional. It streams Gemini responses, maintains conversation context, and lets you control the AI's personality and creativity. Next up, you will build the React frontend to display these streaming responses in a real chat interface.
Build the React Chat Frontend
Your backend is streaming AI responses. Now you need a frontend that can catch those responses as they arrive and display them in real-time.
In this step, you'll build a React chat interface that sends messages to your Express server and renders the AI's reply word-by-word as it streams in. This is the same pattern used by ChatGPT and other AI chat products.
In this step, get ready to:
- Build a styled chat interface with CSS that distinguishes user messages from AI messages.
- Connect to the streaming backend using the Fetch API with a ReadableStream reader.
- Add a loading indicator, disable the input during generation, and auto-scroll to the latest message.
Build the chat interface and style it
A chat app needs two visual components: a scrollable area that displays messages and an input form for typing new ones. You'll create all the CSS first, then set up the React component's state.
- In VS Code, open client/src/App.css and replace all of its contents with the following styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.app {
max-width: 800px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.25rem;
}
.controls {
display: flex;
gap: 16px;
align-items: center;
}
.controls label {
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 8px;
}
.controls button {
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
}
.message.user {
background: #007bff;
color: white;
align-self: flex-end;
}
.message.model {
background: #f0f0f0;
align-self: flex-start;
}
.message .role {
font-size: 0.75rem;
font-weight: 600;
display: block;
margin-bottom: 4px;
opacity: 0.7;
}
.message p {
white-space: pre-wrap;
line-height: 1.5;
}
.input-form {
display: flex;
gap: 8px;
padding: 16px;
border-top: 1px solid #e0e0e0;
}
.input-form input {
flex: 1;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 1rem;
}
.input-form button {
padding: 12px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
}
.input-form button:disabled {
background: #ccc;
cursor: not-allowed;
}
What do these styles do?
- .app creates a full-height flex column layout, centered on the page at 800px max width.
- .message.user aligns user messages to the right with a blue background. .message.model aligns AI messages to the left with a grey background.
- .messages uses flex: 1 to fill all available vertical space and overflow-y: auto to make it scrollable when messages overflow.
- .input-form button:disabled grays out the send button when the AI is still generating.
- Save App.css.
- Now open client/src/App.jsx and replace all of its contents with the following component setup:
import { useState, useRef, useEffect } from "react";
import "./App.css";
function App() {
// State for the list of chat messages
const [messages, setMessages] = useState([]);
// State for the current text input value
const [input, setInput] = useState("");
// Tracks whether the AI is currently generating a response
const [isLoading, setIsLoading] = useState(false);
// Controls how creative/random the AI's responses are
const [temperature, setTemperature] = useState(1.0);
// The system prompt that shapes the AI's personality
const [systemInstruction, setSystemInstruction] = useState(
"You are a helpful assistant."
);
// A ref to scroll to the bottom of the messages list
const messagesEndRef = useRef(null);
// Auto-scroll to the newest message whenever messages update
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
What does this code do?
- messages stores every message in the conversation as objects with role and text fields.
- isLoading tracks whether the AI is generating. You'll use this to disable the input and show a loading indicator.
- temperature and systemInstruction are sent with each request so the backend knows how to configure Gemini.
- messagesEndRef is a React ref attached to an invisible element at the bottom of the messages list. The useEffect scrolls to it every time messages change.
- Save App.jsx.
The file is not complete yet. You'll add the sendMessage function next, then the JSX return that renders the chat UI.
Connect to the streaming backend
The sendMessage function is the core of the frontend. It sends the conversation to your Express server, then reads the Server-Sent Events stream chunk by chunk, updating the AI's message in real-time.
- In App.jsx, add the following sendMessage function directly below the useEffect block:
const sendMessage = async (e) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
// Create the user message and add it to state
const userMessage = { role: "user", text: input.trim() };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput("");
setIsLoading(true);
// Reserve a spot for the AI's response
const aiMessageIndex = updatedMessages.length;
setMessages((prev) => [...prev, { role: "model", text: "" }]);
try {
// Send the full 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,
systemInstruction,
temperature,
}),
});
// Read the SSE stream using a ReadableStream reader
const reader = response.body.getReader();
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: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.text) {
// Append each chunk to the AI message in real-time
setMessages((prev) => {
const updated = [...prev];
updated[aiMessageIndex] = {
...updated[aiMessageIndex],
text: updated[aiMessageIndex].text + parsed.text,
};
return updated;
});
}
} catch {
// skip non-JSON lines
}
}
}
}
} catch (error) {
// Show a friendly error if the server is unreachable
setMessages((prev) => {
const updated = [...prev];
updated[aiMessageIndex] = {
role: "model",
text: "Error: Could not reach the server.",
};
return updated;
});
}
setIsLoading(false);
};
What does this code do?
This function handles the entire request-response cycle for streaming:
- It appends the user's message to state, clears the input, then reserves an empty AI message slot at aiMessageIndex.
- It sends the full conversation (including history) to your Express backend via a POST request.
- response.body.getReader() gives you a ReadableStream reader. Each reader.read() call returns the next chunk of SSE data as it arrives from the server.
- Each SSE line starts with data: . The code strips that prefix, parses the JSON, and appends parsed.text to the AI message. This makes text appear word-by-word.
- When the server sends [DONE], the loop exits. If the server is unreachable, the catch block shows an error message.
- Save App.jsx.
Seeing a red underline on sendMessage?
Your editor might show an error because sendMessage is defined but not used yet. That will resolve once you add the JSX return in the next substep.
Help me debug this error in my App.jsx.
Add the loading indicator, disabled input, and auto-scroll
The final piece is the JSX that renders everything: the header with controls, the scrollable message list, a loading state, and the input form.
- In App.jsx, add the following clearChat function, return statement, and export directly below the sendMessage function:
const clearChat = () => {
setMessages([]);
};
return (
<div className="app">
<header className="header">
<h1>AI Chat Assistant</h1>
<div className="controls">
<label>
Temperature: {temperature.toFixed(1)}
<input
type="range"
min="0"
max="2"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
/>
</label>
<button onClick={clearChat}>New Chat</button>
</div>
</header>
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
<span className="role">{msg.role === "user" ? "You" : "AI"}</span>
<p>{msg.text}</p>
</div>
))}
{isLoading && messages[messages.length - 1]?.text === "" && (
<div className="message model loading">
<span className="role">AI</span>
<p>Thinking...</p>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form className="input-form" 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 this code do?
- clearChat resets the messages array to start a fresh conversation.
- The temperature slider sends a value between 0 and 2 with each request. The label shows the current value using toFixed(1).
- The messages.map() loop renders each message with a user or model class so CSS can style them differently.
- The loading indicator only appears when isLoading is true AND the last message has empty text (meaning the AI hasn't started responding yet).
- Both the input field and the submit button are disabled while isLoading is true, preventing duplicate requests.
- Save App.jsx.
- Open a second terminal in VS Code by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
- Start the Vite dev server by running:
cd client
npm run dev
- Open your browser to http://localhost:5173.
- Type a message in the input field and press Send.
You should see the chat interface with a header showing "AI Chat Assistant", a temperature slider, and a "New Chat" button. The AI's response should appear word-by-word in a grey bubble on the left.
Not seeing AI responses stream in?
- Make sure your backend is still running in the other terminal on port 3001. If you stopped it, run npm start again from the server folder.
- Open your browser's developer console (F12) and check for CORS or network errors. A Failed to fetch error means the backend is not reachable.
- Check that http://localhost:3001/api/chat in your sendMessage function matches the port your server is running on.
Help me debug why my React frontend isn't connecting to the Express backend.
✔️ Awesome, I've got everything!
Great. Double check you've saved both App.jsx and App.css.
ⓧ I'd like to double check the full code
Here is the complete client/src/App.jsx:
import { useState, useRef, useEffect } from "react";
import "./App.css";
function App() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [temperature, setTemperature] = useState(1.0);
const [systemInstruction, setSystemInstruction] = useState(
"You are a helpful assistant."
);
const messagesEndRef = useRef(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const sendMessage = async (e) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const userMessage = { role: "user", text: input.trim() };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput("");
setIsLoading(true);
const aiMessageIndex = updatedMessages.length;
setMessages((prev) => [...prev, { role: "model", text: "" }]);
try {
const response = await fetch("http://localhost:3001/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: updatedMessages,
systemInstruction,
temperature,
}),
});
const reader = response.body.getReader();
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: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.text) {
setMessages((prev) => {
const updated = [...prev];
updated[aiMessageIndex] = {
...updated[aiMessageIndex],
text: updated[aiMessageIndex].text + parsed.text,
};
return updated;
});
}
} catch {
// skip non-JSON lines
}
}
}
}
} catch (error) {
setMessages((prev) => {
const updated = [...prev];
updated[aiMessageIndex] = {
role: "model",
text: "Error: Could not reach the server.",
};
return updated;
});
}
setIsLoading(false);
};
const clearChat = () => {
setMessages([]);
};
return (
<div className="app">
<header className="header">
<h1>AI Chat Assistant</h1>
<div className="controls">
<label>
Temperature: {temperature.toFixed(1)}
<input
type="range"
min="0"
max="2"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
/>
</label>
<button onClick={clearChat}>New Chat</button>
</div>
</header>
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
<span className="role">{msg.role === "user" ? "You" : "AI"}</span>
<p>{msg.text}</p>
</div>
))}
{isLoading && messages[messages.length - 1]?.text === "" && (
<div className="message model loading">
<span className="role">AI</span>
<p>Thinking...</p>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form className="input-form" 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;
Here is the complete client/src/App.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.app {
max-width: 800px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.25rem;
}
.controls {
display: flex;
gap: 16px;
align-items: center;
}
.controls label {
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 8px;
}
.controls button {
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
}
.message.user {
background: #007bff;
color: white;
align-self: flex-end;
}
.message.model {
background: #f0f0f0;
align-self: flex-start;
}
.message .role {
font-size: 0.75rem;
font-weight: 600;
display: block;
margin-bottom: 4px;
opacity: 0.7;
}
.message p {
white-space: pre-wrap;
line-height: 1.5;
}
.input-form {
display: flex;
gap: 8px;
padding: 16px;
border-top: 1px solid #e0e0e0;
}
.input-form input {
flex: 1;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 1rem;
}
.input-form button {
padding: 12px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
}
.input-form button:disabled {
background: #ccc;
cursor: not-allowed;
}
Your chat frontend is fully connected to the streaming backend. Next up, you'll add interactive controls that let you experiment with temperature and conversation history to understand how LLMs behave.
Add Temperature Control and Conversation History
Your streaming chat UI is fully wired up. Messages flow from the frontend to Gemini and back in real-time. But you haven't yet explored the two controls that make this app feel like a real AI product: temperature and conversation history.
In this step, you'll put your application through its paces. You'll learn what temperature actually controls inside an LLM, see how conversation history gives the AI memory, and run a full end-to-end test.
In this step, get ready to:
- Explore how the temperature slider controls AI response creativity.
- Test multi-turn conversation history and the New Chat button.
- Run the complete application end-to-end with different settings.
Understand how temperature controls creativity
Your app already has a temperature slider in the header. Temperature is one of the most important parameters when working with any LLM API.
- Switch to your browser at http://localhost:5173.
- Find the temperature slider in the top-right corner of the header. It shows the current value (default 1.0).
- Drag the slider all the way to the left (0.0).
- Type this message and press **Send**:
Tell me a one-sentence joke about programming.
- Wait for the response to stream in.
- Click **New Chat** to clear the conversation.
- Drag the slider all the way to the right (2.0).
- Send the exact same message again.
You should notice the responses are very different. At 0.0, the AI picks the most likely next word every time, giving focused and predictable output. At 2.0, it explores more random word choices, producing creative (sometimes wild) results.
What is temperature?
Temperature controls how "random" the model's word choices are. When the AI generates text, it calculates a probability for every possible next word. Temperature scales those probabilities before picking one.
At 0.0 (deterministic), the model always picks the highest-probability word. At 2.0 (maximum creativity), lower-probability words get a much higher chance of being selected. A value of 1.0 is the default balance.
Look at the code that sends the temperature value. In App.jsx, the sendMessage function includes temperature in every request:
body: JSON.stringify({
messages: updatedMessages,
systemInstruction,
temperature,
}),
How does the backend use temperature?
Your Express server passes this value directly to Gemini's config.temperature field. If the frontend doesn't send a value, the backend defaults to 1.0 using the nullish coalescing operator (??).
Test multi-turn conversation history
LLMs are stateless by default. Each API call is independent, with no memory of what came before. To give the AI "memory", your app sends the entire conversation history with every request.
- Click **New Chat** to start fresh.
- Set the temperature slider to 1.0 (the middle).
- Send this first message:
My name is Alex and I'm learning about AI APIs.
- Wait for the AI to respond.
- Now send a follow-up message:
What is my name?
The AI should correctly respond with "Alex". This proves conversation history is working. Without it, the model would have no idea what your name is.
How does conversation history work?
Your frontend stores every message in the messages state array. Each time you send a new message, the entire array (including all previous messages) goes to the backend. The backend formats this into Gemini's expected contents format (an array of {role, parts} objects).
This is how every ChatGPT-style product works. The AI itself has no memory. The client is responsible for sending the full conversation each time.
- Click **New Chat** to clear the conversation.
- Send the message "What is my name?" again.
This time, the AI should say it doesn't know your name. The **New Chat** button calls clearChat() which resets the messages array to empty. With no prior messages sent, Gemini has no context about you.
What are tokens?
Every word you send costs tokens. Tokens are the units LLMs use to measure text. One token is roughly 4 characters or 0.75 words. Sending full conversation history means each new message costs more tokens than the last, because you're re-sending everything.
This is why the **New Chat** button matters. Clearing history resets the token cost back to just your single new message.
Run the complete application end-to-end
Time for a full integration test. You'll verify that every feature works together: streaming, temperature, history, and the loading states.
- Confirm both servers are still running. Your backend terminal should show Server running on http://localhost:3001 and your frontend terminal should show the Vite dev server URL.
- Click **New Chat** to start with a clean slate.
- Set temperature to 0.0.
- Send a message and watch for the "Thinking..." indicator to appear.
- Verify that text streams in word-by-word (not all at once).
- Notice that the input field is disabled while the AI is responding.
- Send a second message that references the first (e.g. "Can you expand on that?").
- Verify the AI understands the context from the first message.
- Change the temperature to 2.0 and ask the same question again to compare the response style.
- Verify that the message list auto-scrolls to the newest message.
Messages not streaming?
If you see the full response appear all at once instead of word-by-word, check your backend terminal for errors. The most common cause is the GEMINI_API_KEY not being loaded correctly from your .env file.
If the AI doesn't remember context between messages, make sure you haven't accidentally clicked **New Chat** between your messages.
Help me debug my streaming chat app
If all tests pass, your application is feature-complete. You have a working AI chat assistant with streaming responses, temperature control, and conversation memory.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete App.jsx:
import { useState, useRef, useEffect } from "react";
import "./App.css";
function App() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [temperature, setTemperature] = useState(1.0);
const [systemInstruction, setSystemInstruction] = useState(
"You are a helpful assistant."
);
const messagesEndRef = useRef(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const sendMessage = async (e) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const userMessage = { role: "user", text: input.trim() };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput("");
setIsLoading(true);
const aiMessageIndex = updatedMessages.length;
setMessages((prev) => [...prev, { role: "model", text: "" }]);
try {
const response = await fetch("http://localhost:3001/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: updatedMessages,
systemInstruction,
temperature,
}),
});
const reader = response.body.getReader();
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: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.text) {
setMessages((prev) => {
const updated = [...prev];
updated[aiMessageIndex] = {
...updated[aiMessageIndex],
text: updated[aiMessageIndex].text + parsed.text,
};
return updated;
});
}
} catch {
// skip non-JSON lines
}
}
}
}
} catch (error) {
setMessages((prev) => {
const updated = [...prev];
updated[aiMessageIndex] = {
role: "model",
text: "Error: Could not reach the server.",
};
return updated;
});
}
setIsLoading(false);
};
const clearChat = () => {
setMessages([]);
};
return (
<div className="app">
<header className="header">
<h1>AI Chat Assistant</h1>
<div className="controls">
<label>
Temperature: {temperature.toFixed(1)}
<input
type="range"
min="0"
max="2"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
/>
</label>
<button onClick={clearChat}>New Chat</button>
</div>
</header>
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
<span className="role">{msg.role === "user" ? "You" : "AI"}</span>
<p>{msg.text}</p>
</div>
))}
{isLoading && messages[messages.length - 1]?.text === "" && (
<div className="message model loading">
<span className="role">AI</span>
<p>Thinking...</p>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form className="input-form" 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;
And here is the complete App.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.app {
max-width: 800px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.25rem;
}
.controls {
display: flex;
gap: 16px;
align-items: center;
}
.controls label {
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 8px;
}
.controls button {
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
}
.message.user {
background: #007bff;
color: white;
align-self: flex-end;
}
.message.model {
background: #f0f0f0;
align-self: flex-start;
}
.message .role {
font-size: 0.75rem;
font-weight: 600;
display: block;
margin-bottom: 4px;
opacity: 0.7;
}
.message p {
white-space: pre-wrap;
line-height: 1.5;
}
.input-form {
display: flex;
gap: 8px;
padding: 16px;
border-top: 1px solid #e0e0e0;
}
.input-form input {
flex: 1;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 1rem;
}
.input-form button {
padding: 12px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
}
.input-form button:disabled {
background: #ccc;
cursor: not-allowed;
}
Your AI chat assistant is fully operational. You've proven that temperature controls creativity, conversation history gives the AI memory, and your streaming architecture delivers responses in real-time. Next up, you'll take this further with the Secret Mission.
Secret mission
AI Personality Switcher
Your chat assistant has one personality right now. What if users could switch between a patient tutor, a creative storyteller, and a strict code reviewer with a single click? Add a personality selector that demonstrates how dramatically system prompts shape AI behavior.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free API key, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Express backend server running on http://localhost:3001.
- Vite React frontend running on http://localhost:5173.
- Project folder on your Desktop containing server/ and client/ directories.
- Gemini API key in Google AI Studio.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your chat assistant.
- Your backend and frontend servers stay running in their terminal tabs.
- Your project files remain on your Desktop.
- Your Gemini API key stays active in Google AI Studio.
Pause - I'll come back to this later
Shut down the running servers to free up terminal sessions and memory. Your files and API key stay intact for when you return.
- Switch to the terminal running your backend server and press Ctrl+C to stop it.
- Switch to the terminal running your frontend server and press Ctrl+C to stop it.
- When you come back, restart the backend with npm start from the server/ folder, and the frontend with npm run dev from the client/ folder.
Delete - I don't want to use this again
Remove all project files and revoke your API key to start completely fresh.
- Stop both running servers by pressing Ctrl+C in each terminal.
- Delete your project folder by running the following commands from your Desktop:
macOS
cd ~/Desktop
rm -rf ai-chat-assistant
Windows
cd ~\Desktop
rmdir /s /q ai-chat-assistant
- Delete your Gemini API key by visiting https://aistudio.google.com/apikey. Find the key you created for this project and delete it from the list.
That's a Wrap!
That's a Wrap!
Nice work! You just built a full-stack AI chat assistant that mirrors the same architecture behind products like ChatGPT and Google's own Gemini web app.
You've learned how to:
- Built an Express backend that securely integrates with the Google Gemini API and streams responses to the client using Server-Sent Events.
- Created a React chat frontend that displays AI responses word-by-word in real-time using a ReadableStream reader.
- Managed multi-turn conversation history so the AI remembers context, and experimented with temperature and system prompts to control AI creativity and behavior.
- Secret Mission: Built a personality switcher that lets users swap between AI personas, demonstrating the power of prompt engineering.
Ready to quiz yourself?