Build an AI Writing Assistant
Integrate the Gemini API into a web app with a Node.js backend proxy.
Introduction
30 Second Summary
Every time you use an AI writing tool, your request travels through an API to reach the AI model and bring a response back to your screen. Understanding this pattern unlocks your ability to build any AI-powered application.
In this project, you will build a web-based AI Writing Assistant that connects to Google's Gemini API. You'll type a topic, pick a writing style, and get AI-generated content delivered through your own backend server.
What You'll Build
A full-stack web app with a Node.js/Express backend that proxies requests to the Gemini API and an HTML/CSS/JavaScript frontend with a chat-style interface.
By the end of this project, you'll have:
- A working Node.js/Express backend server that securely calls Google's Gemini API and protects your API key from being exposed in the browser.
- A chat-style frontend interface where you type topics and receive AI-generated content in different writing styles (poem, story, professional email, ELI5 explanation).
- A multi-turn conversation memory system so the AI can refine its responses based on your follow-up messages.
- Secret Mission: add streaming responses so text appears word-by-word in real time.
Are there any prerequisites?
You should be comfortable with basic HTML, CSS, and JavaScript. If you can write a function in JS and understand how a web page loads, you have everything you need.
Set Up Your Project
Every web application that connects to an external API needs three things: a project folder with dependencies, an API key from the service you want to call, and a file structure to organize your code.
In this step, you will create your project folder, install Express (the framework that powers your backend server), grab a free API key from Google, and set up the files you will work in for the rest of the project.
In this step, get ready to:
- Create your project folder and install Express.
- Get your free Gemini API key from Google AI Studio.
- Create the file structure for your frontend and backend code.
Create your project folder and install Express
Your project needs a home on your computer and a package.json file that tracks its dependencies. The npm tool (bundled with Node.js) handles both.
- Confirm Node.js is installed by running this command in your terminal:
node --version
✔️ I see a version number
Node.js is ready. Move on to the next instruction below.
ⓧ Command not found
Node.js is not installed yet. Download and install it from the official website:
- Go to https://nodejs.org/en/download.
- Download the LTS version for your operating system.
- Run the installer and accept the defaults.
- Close and reopen your terminal, then run node --version again to confirm it works.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ai-writing-assistant
cd ai-writing-assistant
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The npm init -y command creates a package.json file in your project folder. This file tracks your project's name, version, and most importantly, its dependencies (other packages your code relies on).
The -y flag accepts all the default values so you do not have to answer prompts.
- Open package.json in your code editor and replace its entire contents with this:
{
"name": "ai-writing-assistant",
"version": "1.0.0",
"description": "AI Writing Assistant powered by Gemini API",
"main": "server.js",
"scripts": {
"start": "node server.js"
}
}
Why replace the defaults?
The auto-generated package.json points main at index.js and includes a placeholder test script. Your project uses server.js as its entry point and a start script that launches the server with node server.js.
- Save package.json.
- Install Express by running:
npm install express
- Confirm the install succeeded by running:
cat package.json
You should see a dependencies section listing express with a version number.
Don't see express in your dependencies?
Make sure you ran npm install express from inside the ai-writing-assistant folder. If your terminal is pointed at a different directory, the package installs there instead.
Run pwd (macOS/Linux) or cd (Windows) to check your current directory. It should end with ai-writing-assistant.
Help me fix my npm install issue.
Get your Gemini API key
Your backend server needs an API key to authenticate with Google's Gemini API. The key is free and does not require a credit card.
- Open https://aistudio.google.com in your browser.
- Sign in with your Google account.
- Click Get API key in the left sidebar.
- Click Create API key.
- Copy the key that appears and paste it somewhere safe (a notes app or a temporary text file on your Desktop).
What does an API key do?
An API key is a unique string that identifies your application to Google's servers. Every request your backend sends to the Gemini API includes this key so Google knows who is making the call.
Your key starts with the letters AI. Keep it private. You will paste it into your server code in the next step.
Free tier limits
The Gemini API free tier gives you 15 requests per minute and 1,500 requests per day on the gemini-2.0-flash model. That is more than enough for building and testing this project.
Having trouble creating a key?
Make sure you are signed in with a personal Google account (not a work or school account). Workspace accounts sometimes have restrictions that block key creation.
If you see a permissions error, try opening AI Studio in an incognito window and signing in again.
Help me get my Gemini API key working.
Create your project file structure
Your project separates backend code (server.js at the root) from frontend code (inside a public folder). This keeps your API key out of anything the browser can access.
- Create the file structure by running these commands from inside your ai-writing-assistant folder:
touch server.js
mkdir public
touch public/index.html public/style.css public/script.js
What are these files for?
- server.js is your backend. It receives requests from the browser and talks to the Gemini API.
- public/index.html is the page your users see in the browser.
- public/style.css controls how the page looks.
- public/script.js handles button clicks and sends messages to your server.
- Confirm the files exist by running:
ls
ls public
You should see server.js, public, package.json, and node_modules in the first listing. The second listing should show index.html, style.css, and script.js.
Don't see all the files?
If touch is not recognized (common on Windows), open your ai-writing-assistant folder in VS Code or Cursor and create the files manually through the file explorer sidebar. Right-click the folder name and select New File.
Make sure you create the public folder first, then create the three frontend files inside it.
Help me create my project files.
✔️ Awesome, I've got everything!
Great. Double check you have saved your package.json and that all five files exist in the correct locations.
ⓧ I'd like to double check the full code
Here is what your package.json should look like after installing Express. Your version number for express may differ slightly.
{
"name": "ai-writing-assistant",
"version": "1.0.0",
"description": "AI Writing Assistant powered by Gemini API",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.2"
}
}
Your other four files (server.js, public/index.html, public/style.css, public/script.js) should exist but are empty for now.
Your project skeleton is ready. Next up, you will write the Express server that connects to the Gemini API and returns AI-generated text.
Build Your Express Server
Your project folder is set up and your API key is ready. Now it's time to build the backend server that will sit between your frontend and the Gemini API.
The server's job is to receive messages from the browser, forward them to Google's Gemini API, and send the AI's response back. This proxy pattern keeps your API key hidden on the server instead of exposing it in browser code.
In this step, get ready to:
- Set up an Express server that serves static files and listens on port 3000.
- Create an API route that calls the Gemini REST endpoint with fetch().
- Add a system instruction that shapes the AI into a creative writing assistant.
Set up the Express server
Express is a lightweight web framework for Node.js. It handles incoming HTTP requests and routes them to the right handler function.
- Open server.js in your editor and add the server setup code:
const express = require("express");
const path = require("path");
const app = express();
const PORT = 3000;
// Replace this with your actual Gemini API key
const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent`;
// Parse JSON request bodies and serve static files from /public
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
What does this code do?
- express.json() is middleware that automatically parses incoming JSON request bodies so you can read them with req.body.
- express.static() serves your frontend files (HTML, CSS, JS) from the public folder without needing separate routes for each file.
- GEMINI_API_KEY stores your key so the server can authenticate with Google's API. The GEMINI_URL points to the Gemini 2.0 Flash model endpoint.
Why is the API key in server.js?
Keeping the key on the server means browsers never see it. If you put it in frontend JavaScript, anyone could open DevTools and steal it. The server acts as a secure proxy between the browser and Google's API.
- Replace your-api-key-here with the Gemini API key you copied earlier.
- Save server.js.
- Start your server by running this command in your terminal:
node server.js
- You should see Server running at http://localhost:3000 printed in your terminal.
- Open your browser and visit http://localhost:3000. You should see a blank page (since index.html is still empty).
Don't see the server running message?
- Make sure you saved server.js before running the command.
- Check that your terminal is inside the ai-writing-assistant folder. Run ls and confirm you can see server.js in the output.
- If you see an error about port 3000 being in use, another process is already using it. Close other terminals or restart your computer.
Help me figure out why my Express server won't start.
Connect the /api/chat route to the Gemini API
Your server can start, but it doesn't do anything useful yet. Now you'll add a route that receives messages from the frontend and forwards them to Google's Gemini API.
- Stop the server by pressing Ctrl+C in your terminal.
- In server.js, find the app.listen line at the bottom.
- Add this route handler directly ABOVE app.listen:
app.post("/api/chat", async (req, res) => {
const { history, style } = req.body;
// Build the request payload for Gemini
const requestBody = {
contents: history,
};
try {
// Send the request to Gemini's REST API
const response = await fetch(GEMINI_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": GEMINI_API_KEY,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json();
return res.status(response.status).json({
error: errorData.error?.message || "Gemini API request failed",
});
}
// Extract the AI's text from the response
const data = await response.json();
const aiMessage = data.candidates[0].content.parts[0].text;
res.json({ message: aiMessage });
} catch (error) {
console.error("Error calling Gemini API:", error);
res.status(500).json({ error: "Failed to connect to AI service" });
}
});
What does this route do?
- app.post("/api/chat") creates a POST endpoint. The frontend will send messages here.
- fetch(GEMINI_URL) makes an HTTP POST request to Google's Gemini API. The x-goog-api-key header authenticates the request with your API key.
- The contents field is how Gemini receives conversation messages. Each entry has a role (user or model) and parts containing the text.
- data.candidates[0].content.parts[0].text extracts the generated text from Gemini's response structure.
- The catch block handles network failures so the frontend gets a clean error message instead of crashing.
- Save server.js.
- Restart the server by running:
node server.js
- Test the route by running this curl command in a new terminal window:
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"history": [{"role": "user", "parts": [{"text": "the ocean"}]}], "style": "poem"}'
- You should see a JSON response containing AI-generated text about the ocean.
Getting an error response?
- If you see "Gemini API request failed", double-check that you replaced your-api-key-here with your actual API key in server.js.
- If you see "Failed to connect to AI service", check your internet connection.
- Make sure you opened a separate terminal window for the curl command. The first terminal is still running the server.
Help me debug my Gemini API connection.
Add a system instruction to shape the AI's behavior
The response you got back was raw AI output with no personality or style constraints. System instructions tell the model how to behave before it sees the user's message.
- In server.js, find this line inside your route handler:
const requestBody = {
contents: history,
};
- Replace that entire requestBody section with this code, which adds a systemInstruction variable and includes it in the request:
const systemInstruction = {
parts: [
{
text: `You are a creative writing assistant. When the user provides a topic, generate content in the following style: ${style}. Be creative, engaging, and tailor your response to the chosen style. For poems, use vivid imagery and rhythm. For stories, create compelling narratives. For emails, be professional and concise. For ELI5, explain simply as if to a five-year-old.`,
},
],
};
const requestBody = {
system_instruction: systemInstruction,
contents: history,
};
What is a system instruction?
A system instruction is a special prompt that runs before the conversation starts. The model reads it first and uses it to shape every response it generates.
In this case, you're telling Gemini to act as a creative writing assistant and tailor its output to whatever style the user selects (poem, story, email, or ELI5). The ${style} variable gets injected from the frontend's dropdown selection.
- Save server.js.
- Stop the server with Ctrl+C and restart it:
node server.js
- Test again with the same curl command from earlier:
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"history": [{"role": "user", "parts": [{"text": "the ocean"}]}], "style": "poem"}'
- This time, the response should be a creative poem about the ocean. The system instruction gives the AI a clear creative writing personality.
Try different styles
Change "style": "poem" to "style": "eli5" or "style": "email" in your curl command to see how the system instruction adapts the AI's output to each style.
Response doesn't match the style?
- Make sure the systemInstruction variable is placed ABOVE the requestBody variable (JavaScript reads top to bottom).
- Check that system_instruction: systemInstruction is inside your requestBody object.
- Verify that your route destructures style from req.body at the top of the handler.
Why isn't my system instruction changing the AI's response style?
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js and your server is running with no errors.
ⓧ I'd like to double check the full code
Here's the complete server.js file at this point:
const express = require("express");
const path = require("path");
const app = express();
const PORT = 3000;
const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent`;
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
app.post("/api/chat", async (req, res) => {
const { history, style } = req.body;
const systemInstruction = {
parts: [
{
text: `You are a creative writing assistant. When the user provides a topic, generate content in the following style: ${style}. Be creative, engaging, and tailor your response to the chosen style. For poems, use vivid imagery and rhythm. For stories, create compelling narratives. For emails, be professional and concise. For ELI5, explain simply as if to a five-year-old.`,
},
],
};
const requestBody = {
system_instruction: systemInstruction,
contents: history,
};
try {
const response = await fetch(GEMINI_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": GEMINI_API_KEY,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json();
return res.status(response.status).json({
error: errorData.error?.message || "Gemini API request failed",
});
}
const data = await response.json();
const aiMessage = data.candidates[0].content.parts[0].text;
res.json({ message: aiMessage });
} catch (error) {
console.error("Error calling Gemini API:", error);
res.status(500).json({ error: "Failed to connect to AI service" });
}
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Your backend is fully wired up to the Gemini API. Next, you'll build the frontend chat interface that sends messages to this server and displays the AI's responses.
Build the Frontend Interface
Your backend server is running and ready to accept requests. Now you need a way for users to actually interact with it.
In this step, you'll build the frontend that talks to your /api/chat endpoint. You'll create a chat-style interface where users can pick a writing style, type a topic, and see AI responses appear in message bubbles.
In this step, get ready to:
- Build the HTML chat interface with a style selector, input field, and message area.
- Style the interface with a dark theme and distinct user and AI message bubbles.
Create the HTML chat structure
The public/index.html file is what your browser loads when you visit http://localhost:3000. Your Express server already serves files from the public folder, so anything you put here is instantly accessible in the browser.
- Open public/index.html in your editor and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Writing Assistant</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<!-- Header section with title and instructions -->
<header class="header">
<h1>AI Writing Assistant</h1>
<p>Choose a style and enter a topic to generate content</p>
</header>
<!-- Chat messages will appear here -->
<div class="messages" id="messages"></div>
<!-- Input area with style dropdown, text input, and send button -->
<div class="input-area">
<select id="style-select">
<option value="poem">Poem</option>
<option value="story">Short Story</option>
<option value="email">Professional Email</option>
<option value="eli5">ELI5 Explanation</option>
</select>
<input
type="text"
id="message-input"
placeholder="Enter a topic or follow-up message..."
/>
<button id="send-button" onclick="sendMessage()">Send</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
What does this HTML do?
This page has three main sections stacked vertically inside the .app container:
- The header displays the app title and a short instruction line.
- The messages div is an empty container. Your JavaScript will inject chat bubbles here later.
- The input-area holds the style dropdown, a text input, and the send button that calls sendMessage() when clicked.
- Save public/index.html.
- Open http://localhost:3000 in your browser (or refresh if you already have it open). You should see the page title, an empty middle area, and a row at the bottom with a dropdown, text input, and a Send button.
Seeing a blank page?
- Make sure you saved the file to public/index.html (inside the public folder, not the project root).
- Check that your server is still running. If it stopped, run node server.js again from your terminal.
Help me debug why my HTML page isn't loading at localhost:3000
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Writing Assistant</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="header">
<h1>AI Writing Assistant</h1>
<p>Choose a style and enter a topic to generate content</p>
</header>
<div class="messages" id="messages"></div>
<div class="input-area">
<select id="style-select">
<option value="poem">Poem</option>
<option value="story">Short Story</option>
<option value="email">Professional Email</option>
<option value="eli5">ELI5 Explanation</option>
</select>
<input
type="text"
id="message-input"
placeholder="Enter a topic or follow-up message..."
/>
<button id="send-button" onclick="sendMessage()">Send</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
Style the chat interface
Right now your page looks like unstyled browser defaults. The public/style.css file will give it a dark theme, position elements in a clean layout, and prepare distinct styles for user messages, AI messages, and error states.
- Open public/style.css and paste the following layout styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #1a1a2e;
color: #eee;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.app {
width: 100%;
max-width: 800px;
height: 90vh;
display: flex;
flex-direction: column;
background-color: #16213e;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
padding: 20px;
background-color: #0f3460;
text-align: center;
}
.header h1 {
font-size: 1.5rem;
margin-bottom: 4px;
}
.header p {
font-size: 0.85rem;
color: #aaa;
}
What does this CSS do?
- The * reset removes default browser spacing so your layout is predictable.
- The body uses flexbox to center the app container on a dark navy background (#1a1a2e).
- The .app container is a vertical flex column capped at 800px wide and 90% of the viewport height.
- Save public/style.css and refresh your browser.
- You should see a dark background with a centered rounded container. The header shows the app title in white text on a deep blue bar.
Still seeing unstyled text?
- Confirm your CSS file is saved as public/style.css (not public/styles.css or in the project root). The filename must match the href in your HTML exactly.
- Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to clear cached styles.
Help me figure out why my CSS isn't loading
- Next, add the message bubble styles below your existing CSS in public/style.css:
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.message.user {
align-self: flex-end;
background-color: #533483;
color: #fff;
}
.message.ai {
align-self: flex-start;
background-color: #0f3460;
color: #e0e0e0;
}
.message.loading {
align-self: flex-start;
background-color: #0f3460;
color: #aaa;
font-style: italic;
}
.loading-dots::after {
content: "";
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0% { content: ""; }
25% { content: "."; }
50% { content: ".."; }
75% { content: "..."; }
}
.message.error {
align-self: flex-start;
background-color: #4a1942;
color: #ff6b6b;
}
What do these styles prepare?
- User messages (.message.user) appear on the right in purple. AI messages (.message.ai) appear on the left in dark blue.
- The .loading-dots animation cycles through one, two, and three dots to show the AI is thinking.
- Error messages get a distinct red-on-dark style so users can immediately tell something went wrong.
- Save public/style.css.
You won't see a visible change yet. These styles apply to message elements that your JavaScript will create in the next step.
- Finally, add the input area styles below your existing CSS in public/style.css:
.input-area {
display: flex;
gap: 8px;
padding: 16px;
background-color: #0f3460;
}
#style-select {
padding: 10px;
border: none;
border-radius: 8px;
background-color: #1a1a2e;
color: #eee;
font-size: 0.9rem;
cursor: pointer;
}
#message-input {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
background-color: #1a1a2e;
color: #eee;
font-size: 1rem;
outline: none;
}
#message-input::placeholder {
color: #666;
}
#send-button {
padding: 10px 20px;
border: none;
border-radius: 8px;
background-color: #533483;
color: #fff;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
#send-button:hover {
background-color: #6a42a0;
}
#send-button:disabled {
background-color: #333;
cursor: not-allowed;
}
What does this section style?
- The .input-area is a horizontal flex row that pins the dropdown, input, and button to the bottom of the app.
- The send button changes color on hover and greys out when disabled (you'll disable it while waiting for a response).
- Save public/style.css and refresh your browser.
- You should see a polished dark interface with a blue header at the top, an empty chat area in the middle, and a styled input row at the bottom. The dropdown, text field, and purple Send button should all blend into the dark theme.
Input area looks broken or misaligned?
- Make sure you pasted each CSS section below the previous one. The styles should flow in order: reset, body, .app, .header, .messages, .message variants, then .input-area.
- Check for missing closing braces. Each selector block needs both an opening { and a closing }.
Help me fix my CSS layout issue
✔️ Awesome, I've got everything!
Great. Make sure you've saved public/style.css before moving on.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #1a1a2e;
color: #eee;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.app {
width: 100%;
max-width: 800px;
height: 90vh;
display: flex;
flex-direction: column;
background-color: #16213e;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
padding: 20px;
background-color: #0f3460;
text-align: center;
}
.header h1 {
font-size: 1.5rem;
margin-bottom: 4px;
}
.header p {
font-size: 0.85rem;
color: #aaa;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.message.user {
align-self: flex-end;
background-color: #533483;
color: #fff;
}
.message.ai {
align-self: flex-start;
background-color: #0f3460;
color: #e0e0e0;
}
.message.loading {
align-self: flex-start;
background-color: #0f3460;
color: #aaa;
font-style: italic;
}
.loading-dots::after {
content: "";
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0% { content: ""; }
25% { content: "."; }
50% { content: ".."; }
75% { content: "..."; }
}
.message.error {
align-self: flex-start;
background-color: #4a1942;
color: #ff6b6b;
}
.input-area {
display: flex;
gap: 8px;
padding: 16px;
background-color: #0f3460;
}
#style-select {
padding: 10px;
border: none;
border-radius: 8px;
background-color: #1a1a2e;
color: #eee;
font-size: 0.9rem;
cursor: pointer;
}
#message-input {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
background-color: #1a1a2e;
color: #eee;
font-size: 1rem;
outline: none;
}
#message-input::placeholder {
color: #666;
}
#send-button {
padding: 10px 20px;
border: none;
border-radius: 8px;
background-color: #533483;
color: #fff;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
#send-button:hover {
background-color: #6a42a0;
}
#send-button:disabled {
background-color: #333;
cursor: not-allowed;
}
Your interface is built and styled. Next up, you'll write the JavaScript that connects this frontend to your backend, sending messages and displaying AI responses in real time.
Connect Frontend to Backend
Your frontend looks great, but right now it does nothing when you click Send. The backend is already listening for requests at /api/chat.
In this step, you will write the JavaScript that bridges the two. Your frontend will collect the user's input, send it to your server, and display the AI's response in the chat window.
In this step, get ready to:
- Write helper functions that display messages and loading indicators in the chat.
- Build the main sendMessage() function that sends requests to your backend and handles responses.
- Test a full end-to-end conversation with the AI.
Create helper functions for the chat display
Before you write the main send logic, you need a few utility functions. These handle adding message bubbles to the chat and showing a loading animation while the AI thinks.
- Open public/script.js in your editor. The file is currently empty.
- Add the conversation history array and the addMessage() helper by pasting this code:
let conversationHistory = [];
function addMessage(text, role) {
// Find the messages container in the DOM
const messagesContainer = document.getElementById("messages");
// Create a new div for this message
const messageDiv = document.createElement("div");
messageDiv.classList.add("message", role);
messageDiv.textContent = text;
messagesContainer.appendChild(messageDiv);
// Auto-scroll to the newest message
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return messageDiv;
}
What does this code do?
conversationHistory is an array that stores every message in the conversation. You will send this entire array to the server with each request so the AI has full context.
addMessage() creates a new div element, applies the appropriate CSS class (user, ai, or error), and appends it to the messages container. It also scrolls the container so the latest message is always visible.
- Below the addMessage() function, add the loading indicator functions:
function showLoading() {
const messagesContainer = document.getElementById("messages");
// Create a loading message with animated dots
const loadingDiv = document.createElement("div");
loadingDiv.classList.add("message", "loading");
loadingDiv.id = "loading-indicator";
loadingDiv.innerHTML = 'Thinking<span class="loading-dots"></span>';
messagesContainer.appendChild(loadingDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function removeLoading() {
// Remove the loading indicator when the response arrives
const loadingDiv = document.getElementById("loading-indicator");
if (loadingDiv) {
loadingDiv.remove();
}
}
What does this code do?
showLoading() creates a temporary message bubble with animated dots (styled by the .loading-dots CSS you wrote earlier). It gives the user visual feedback that the AI is processing.
removeLoading() finds that loading indicator by its id and removes it from the DOM once the response arrives.
- Save public/script.js.
- Refresh http://localhost:3000 in your browser. The page loads without console errors (open DevTools with Cmd+Option+J on macOS or Ctrl+Shift+J on Windows to confirm).
Seeing a console error?
If you see a syntax error, check that the innerHTML string in showLoading() uses matching quotes. The outer string uses single quotes and the class attribute inside uses double quotes.
help me fix a syntax error in my script.js loading function
Build the sendMessage function
This is the core function that ties everything together. When the user clicks Send, this function collects the input, displays it in the chat, calls your backend, and renders the AI's response.
- Below your removeLoading() function in public/script.js, add the sendMessage() function:
async function sendMessage() {
const input = document.getElementById("message-input");
const styleSelect = document.getElementById("style-select");
const sendButton = document.getElementById("send-button");
// Get the user's message and bail if empty
const messageText = input.value.trim();
if (!messageText) return;
const style = styleSelect.value;
// Display the user's message in the chat
addMessage(messageText, "user");
input.value = "";
sendButton.disabled = true;
// Add to conversation history for multi-turn context
conversationHistory.push({
role: "user",
parts: [{ text: messageText }],
});
showLoading();
try {
// Send the conversation to our backend
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
history: conversationHistory,
style: style,
}),
});
removeLoading();
if (!response.ok) {
const errorData = await response.json();
addMessage(errorData.error || "Something went wrong", "error");
conversationHistory.pop();
return;
}
const data = await response.json();
// Add the AI response to conversation history
conversationHistory.push({
role: "model",
parts: [{ text: data.message }],
});
addMessage(data.message, "ai");
} catch (error) {
removeLoading();
addMessage("Failed to connect to the server. Please try again.", "error");
conversationHistory.pop();
} finally {
sendButton.disabled = false;
input.focus();
}
}
What does this code do?
The function reads the text from message-input and the selected style from style-select. It immediately displays the user's message in the chat and disables the send button to prevent duplicate requests.
The fetch() call sends a POST request to /api/chat with the full conversation history and selected style as JSON. The Content-Type: application/json header tells your Express server how to parse the body.
If the response is not OK (status 4xx or 5xx), it shows the error message and removes the failed user message from the history with conversationHistory.pop(). The catch block handles network failures. The finally block always re-enables the send button.
- Below sendMessage(), add a keyboard event listener so pressing Enter also sends messages:
document.getElementById("message-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
sendMessage();
}
});
What does this code do?
This listens for keydown events on the message input field. When the user presses the Enter key, it calls sendMessage() automatically. This feels more natural than always clicking the button.
- Save public/script.js.
- Refresh http://localhost:3000 in your browser.
- Type a topic (for example, the ocean) into the input field.
- Select a style from the dropdown (for example, Poem).
- Click Send or press Enter.
You should see your message appear as a purple bubble on the right, a "Thinking..." animation, and then the AI's response in a dark blue bubble on the left.
Not getting a response from the AI?
If you see an error message in a red bubble, check your terminal for error output. The most common cause is an invalid API key in server.js. Make sure you replaced your-api-key-here with your actual Gemini API key from the earlier step.
If you see nothing at all (no loading indicator, no error), open the browser DevTools console. A ReferenceError means a function name is misspelled. Check that sendMessage matches the onclick in your HTML button.
help me debug why my AI writing assistant is not returning responses
✔️ Awesome, I've got everything!
Great. Double check you have saved public/script.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete public/script.js at this point:
let conversationHistory = [];
function addMessage(text, role) {
const messagesContainer = document.getElementById("messages");
const messageDiv = document.createElement("div");
messageDiv.classList.add("message", role);
messageDiv.textContent = text;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return messageDiv;
}
function showLoading() {
const messagesContainer = document.getElementById("messages");
const loadingDiv = document.createElement("div");
loadingDiv.classList.add("message", "loading");
loadingDiv.id = "loading-indicator";
loadingDiv.innerHTML = 'Thinking<span class="loading-dots"></span>';
messagesContainer.appendChild(loadingDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function removeLoading() {
const loadingDiv = document.getElementById("loading-indicator");
if (loadingDiv) {
loadingDiv.remove();
}
}
async function sendMessage() {
const input = document.getElementById("message-input");
const styleSelect = document.getElementById("style-select");
const sendButton = document.getElementById("send-button");
const messageText = input.value.trim();
if (!messageText) return;
const style = styleSelect.value;
addMessage(messageText, "user");
input.value = "";
sendButton.disabled = true;
conversationHistory.push({
role: "user",
parts: [{ text: messageText }],
});
showLoading();
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
history: conversationHistory,
style: style,
}),
});
removeLoading();
if (!response.ok) {
const errorData = await response.json();
addMessage(errorData.error || "Something went wrong", "error");
conversationHistory.pop();
return;
}
const data = await response.json();
conversationHistory.push({
role: "model",
parts: [{ text: data.message }],
});
addMessage(data.message, "ai");
} catch (error) {
removeLoading();
addMessage("Failed to connect to the server. Please try again.", "error");
conversationHistory.pop();
} finally {
sendButton.disabled = false;
input.focus();
}
}
document.getElementById("message-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
sendMessage();
}
});
Your frontend and backend are now talking to each other. Next up, you will add conversation memory so the AI can remember earlier messages and refine its responses based on your follow-ups.
Add Conversation Memory
Your AI Writing Assistant can generate content from a single prompt. But real conversations involve follow-ups.
What if you want to say "make it shorter" or "change the tone to be funnier"? The AI needs to remember what it said before. In this step, you'll trace through how your code already supports multi-turn conversation and verify it works.
In this step, get ready to:
- Understand how the server sends the full conversation history to Gemini.
- Verify multi-turn conversation works by asking the AI to refine its responses.
Understand how the server handles conversation history
Most AI APIs are stateless. They do not remember anything between requests. To create a conversation, the client must send the entire message history with every single request.
What is multi-turn conversation?
A multi-turn conversation sends the full history of messages (alternating between user and AI) with each new request. The AI reads the entire thread to understand context before generating its next response.
This is how every chatbot (including ChatGPT) works under the hood. The more history you send, the more context the AI has to work with.
- In server.js, find the /api/chat route. Look at these key lines:
app.post("/api/chat", async (req, res) => {
const { history, style } = req.body;
const systemInstruction = {
parts: [
{
text: `You are a creative writing assistant. When the user provides a topic, generate content in the following style: ${style}. Be creative, engaging, and tailor your response to the chosen style. For poems, use vivid imagery and rhythm. For stories, create compelling narratives. For emails, be professional and concise. For ELI5, explain simply as if to a five-year-old.`,
},
],
};
const requestBody = {
system_instruction: systemInstruction,
contents: history,
};
What does this code do?
The contents: history line is what makes multi-turn conversation work. Instead of sending a single message to Gemini, it sends the entire array of messages with alternating user and model roles.
The Gemini API expects this format: each item in the contents array has a role (either user or model) and a parts array containing the text. Gemini reads the entire thread to understand what came before.
- Now switch to public/script.js. Find the sendMessage() function and look at how it builds the history:
conversationHistory.push({
role: "user",
parts: [{ text: messageText }],
});
showLoading();
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
history: conversationHistory,
style: style,
}),
});
removeLoading();
if (!response.ok) {
const errorData = await response.json();
addMessage(errorData.error || "Something went wrong", "error");
conversationHistory.pop();
return;
}
const data = await response.json();
conversationHistory.push({
role: "model",
parts: [{ text: data.message }],
});
addMessage(data.message, "ai");
How does the conversation history grow?
Each time the user sends a message, it gets pushed to conversationHistory with role: "user" before the request is made. After the AI responds, the response gets pushed with role: "model".
The full array is sent to the server on every request. So if you have a 5-message conversation, the 5th request includes all previous messages for context.
Notice that if a request fails, conversationHistory.pop() removes the user's message. This prevents broken messages from polluting the history.
Test multi-turn conversation
Time to see conversation memory in action. You will send a prompt, get a response, and then ask the AI to refine it.
- If your server is not running, start it by running:
node server.js
- Open http://localhost:3000 in your browser.
- Select Poem from the style dropdown.
- Type the ocean in the input field and click Send.
- Wait for the AI to respond with a poem.
- Now type make it shorter and click Send again.
The AI responds with a shorter version of the same poem. It understood "make it shorter" because it received the full conversation history, including the original poem it generated.
- Try one more follow-up. Type change the tone to be darker and click Send.
The AI modifies its poem again, keeping it short (from your second message) while making it darker. Each response builds on everything that came before.
AI not remembering previous messages?
If the AI generates a completely new poem instead of modifying the previous one, check that conversationHistory is declared at the top of public/script.js (outside the sendMessage() function). If it is inside the function, it resets to an empty array every time.
Also verify that your server's requestBody uses contents: history and not a hardcoded single-message array.
Help me debug my multi-turn conversation
✔️ Awesome, I've got everything!
Great. Double check you have saved both server.js and public/script.js.
ⓧ I'd like to double check the full code
Here is the full server.js:
const express = require("express");
const path = require("path");
const app = express();
const PORT = 3000;
const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent`;
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
app.post("/api/chat", async (req, res) => {
const { history, style } = req.body;
const systemInstruction = {
parts: [
{
text: `You are a creative writing assistant. When the user provides a topic, generate content in the following style: ${style}. Be creative, engaging, and tailor your response to the chosen style. For poems, use vivid imagery and rhythm. For stories, create compelling narratives. For emails, be professional and concise. For ELI5, explain simply as if to a five-year-old.`,
},
],
};
const requestBody = {
system_instruction: systemInstruction,
contents: history,
};
try {
const response = await fetch(GEMINI_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": GEMINI_API_KEY,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json();
return res.status(response.status).json({
error: errorData.error?.message || "Gemini API request failed",
});
}
const data = await response.json();
const aiMessage = data.candidates[0].content.parts[0].text;
res.json({ message: aiMessage });
} catch (error) {
console.error("Error calling Gemini API:", error);
res.status(500).json({ error: "Failed to connect to AI service" });
}
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Here is the full public/script.js:
let conversationHistory = [];
function addMessage(text, role) {
const messagesContainer = document.getElementById("messages");
const messageDiv = document.createElement("div");
messageDiv.classList.add("message", role);
messageDiv.textContent = text;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return messageDiv;
}
function showLoading() {
const messagesContainer = document.getElementById("messages");
const loadingDiv = document.createElement("div");
loadingDiv.classList.add("message", "loading");
loadingDiv.id = "loading-indicator";
loadingDiv.innerHTML = 'Thinking<span class="loading-dots"></span>';
messagesContainer.appendChild(loadingDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function removeLoading() {
const loadingDiv = document.getElementById("loading-indicator");
if (loadingDiv) {
loadingDiv.remove();
}
}
async function sendMessage() {
const input = document.getElementById("message-input");
const styleSelect = document.getElementById("style-select");
const sendButton = document.getElementById("send-button");
const messageText = input.value.trim();
if (!messageText) return;
const style = styleSelect.value;
addMessage(messageText, "user");
input.value = "";
sendButton.disabled = true;
conversationHistory.push({
role: "user",
parts: [{ text: messageText }],
});
showLoading();
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
history: conversationHistory,
style: style,
}),
});
removeLoading();
if (!response.ok) {
const errorData = await response.json();
addMessage(errorData.error || "Something went wrong", "error");
conversationHistory.pop();
return;
}
const data = await response.json();
conversationHistory.push({
role: "model",
parts: [{ text: data.message }],
});
addMessage(data.message, "ai");
} catch (error) {
removeLoading();
addMessage("Failed to connect to the server. Please try again.", "error");
conversationHistory.pop();
} finally {
sendButton.disabled = false;
input.focus();
}
}
document.getElementById("message-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
sendMessage();
}
});
Your AI Writing Assistant is fully functional. You have a working web app that generates creative content, handles errors gracefully, and remembers the conversation so you can iterate on responses. This is the same architecture that powers production AI applications.
Secret mission
Add Streaming Responses
Your AI assistant works, but the responses pop in all at once after a wait. Real AI apps like ChatGPT show text appearing word-by-word as the model generates it. Can you add streaming responses to your writing assistant using Server-Sent Events?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses the Gemini API free tier, 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:
- The ai-writing-assistant project folder on your Desktop (including node_modules).
- The Node.js server process running on port 3000.
- Your Gemini API key (free tier, no charges).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your AI Writing Assistant.
- Your server will keep running until you close the terminal or restart your computer.
- Your Gemini API key remains active on the free tier with no charges.
- Your project files stay on your Desktop.
Pause - I'll come back to this later
Stop the running server to free up the terminal, but keep your files for later.
- Switch to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Your project files and API key remain in place for when you return.
Delete - I don't want to use this again
Remove all project resources and revoke your API key to start completely fresh.
- Switch to the terminal where your server is running and press Ctrl+C to stop it.
- Delete the project folder by running these commands:
macOS
cd ~/Desktop
rm -rf ai-writing-assistant
Windows
cd ~\Desktop
rmdir /s /q ai-writing-assistant
- Delete your Gemini API key by opening the Google Cloud API Credentials page.
- Find the API key you created for this project and click the Actions icon.
- Select Delete API key, then confirm by clicking Delete in the modal.
Nice Work!
Nice Work!
You just built a fully functional AI Writing Assistant powered by Google's Gemini API. You can type any topic, pick a writing style, and get creative content generated in real time.
You've learned how to:
- Built a Node.js/Express backend server that securely proxies requests to the Gemini REST API, keeping your API key hidden from the browser.
- Connected a frontend to your backend using fetch() with POST requests and JSON payloads, handling both success and error responses gracefully.
- Implemented multi-turn conversation memory using the contents array pattern with alternating user and model roles, letting the AI refine its responses based on follow-up messages.
- Secret Mission: Added real-time streaming responses using Server-Sent Events so text appears word-by-word in the chat.
Ready to quiz yourself?