Build AI Chatbot Using React and OpenAI: End-to-End Complete Notes
Learn how to build a real AI chatbot with React, a secure Node.js backend, and the OpenAI Responses API. This guide covers architecture, setup, chat UI, API routes, streaming, prompt design, security, deployment, and common production fixes.
Published: May 2026 Updated: May 2026
Introduction
An AI chatbot looks simple from the outside: a user types a message, the app sends it to an AI model, and the answer appears in the chat window. A production-quality chatbot, however, needs a few important pieces: a clean React interface, a backend that protects your OpenAI API key, message history handling, loading states, error handling, rate limiting, streaming responses, and deployment configuration.
This tutorial builds a complete React and OpenAI chatbot from scratch. The frontend uses React with Vite. The backend uses Node.js and Express. The AI integration uses the OpenAI JavaScript SDK and the Responses API, which OpenAI documents as the modern endpoint for text generation and model interactions. The examples use a practical model choice, but the structure lets you change models later without rewriting the whole app.
By the end, you will have a working full-stack chatbot that can answer questions, preserve conversation context, stream responses, and be deployed safely. You will also understand why the API key must stay on the server and how to debug common errors like CORS, missing environment variables, 401, 429, slow responses, and broken streaming.
What You Will Build
The final app has two parts:
- React frontend: chat screen, message list, input form, send button, typing state, markdown-friendly message layout, and optional streaming display.
- Node.js backend: Express API route that receives messages from React, calls OpenAI securely, and returns the model response.
| Layer | Technology | Responsibility |
|---|---|---|
| Frontend | React + Vite | Display chat UI, collect user input, call backend API |
| Backend | Node.js + Express | Protect API key, validate requests, call OpenAI |
| AI API | OpenAI Responses API | Generate assistant responses from conversation input |
| Config | .env | Store API key, model name, allowed origin, port |
| Deployment | Vercel/Netlify + Render/Railway/Fly/Node host | Host frontend and backend with environment variables |
The key rule is simple: React should never call OpenAI directly with your secret key. Browser code is public. Anyone can inspect JavaScript bundles and steal exposed keys. The backend acts as a safe API proxy.
Prerequisites
- Basic React knowledge: components, state, props, forms, hooks.
- Basic Node.js knowledge: npm, Express routes, environment variables.
- Node.js 20+ recommended.
- An OpenAI API key from the OpenAI platform dashboard.
- A code editor such as VS Code.
The project is intentionally framework-light. You can later move the same backend logic into Next.js API routes, Laravel, CodeIgniter, Django, Spring Boot, or serverless functions.
Project Structure
Create one folder with separate frontend and backend apps. Keeping them separate makes deployment and security easier to understand.
react-openai-chatbot/
client/
src/
App.jsx
main.jsx
styles.css
index.html
package.json
server/
src/
index.js
openai.js
.env
.env.example
package.json
README.md
The frontend only knows about your backend URL. The backend knows about the OpenAI API key.
Step 1: Create the React Frontend
Use Vite because it is fast, simple, and works well for React projects.
mkdir react-openai-chatbot
cd react-openai-chatbot
npm create vite@latest client -- --template react
cd client
npm install
npm run dev
Open the local URL shown by Vite. You should see the default React starter page. We will replace it with the chatbot interface.
Step 2: Create the Node.js Backend
Now create the server app. This backend receives chat messages from React and calls OpenAI.
cd ..
mkdir server
cd server
npm init -y
npm install express cors dotenv openai
npm install --save-dev nodemon
Update server/package.json so Node uses ES modules and nodemon can run the app during development.
{
"name": "react-openai-chatbot-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.19.2",
"openai": "^4.0.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
If a newer OpenAI SDK version is available when you build, install the latest package with npm install openai@latest.
Step 3: Add Environment Variables
Create server/.env. Do not commit this file.
OPENAI_API_KEY=sk-your-api-key-here
OPENAI_MODEL=gpt-5.4-mini
PORT=5000
CLIENT_ORIGIN=http://localhost:5173
Create server/.env.example for teammates or deployment notes.
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.4-mini
PORT=5000
CLIENT_ORIGIN=http://localhost:5173
Model note: OpenAI's current docs show the Responses API examples with GPT-5 family models, and the model page recommends the flagship model for complex reasoning while smaller variants are better when latency and cost matter. For a normal website chatbot, start with a smaller model such as gpt-5.4-mini, then upgrade if your quality requirements demand it.
Step 4: Create the OpenAI Client
Create server/src/openai.js.
import OpenAI from "openai";
export const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export const model = process.env.OPENAI_MODEL || "gpt-5.4-mini";
The official SDK reads cleanly in server-side JavaScript. Keep this file small so your route logic stays easy to test.
Step 5: Build a Basic Chat API
Create server/src/index.js. This first version returns the full answer after OpenAI completes generation.
import "dotenv/config";
import express from "express";
import cors from "cors";
import { openai, model } from "./openai.js";
const app = express();
const port = process.env.PORT || 5000;
app.use(cors({
origin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
}));
app.use(express.json({ limit: "1mb" }));
app.get("/health", (req, res) => {
res.json({ ok: true });
});
app.post("/api/chat", async (req, res) => {
try {
const { messages } = req.body;
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: "messages array is required" });
}
const safeMessages = messages.slice(-12).map((message) => ({
role: message.role === "assistant" ? "assistant" : "user",
content: String(message.content || "").slice(0, 4000),
}));
const response = await openai.responses.create({
model,
instructions: "You are a helpful AI tutor. Give clear, practical answers. If code is useful, include concise examples.",
input: safeMessages,
});
res.json({
message: response.output_text,
});
} catch (error) {
console.error("Chat API error:", error);
res.status(500).json({
error: "Unable to generate a response right now.",
});
}
});
app.listen(port, () => {
console.log(`Chatbot server running on http://localhost:${port}`);
});
Run the backend:
cd server
npm run dev
Test it with curl or Postman:
curl -X POST http://localhost:5000/api/chat \
-H "Content-Type: application/json" \
-d "{\"messages\":[{\"role\":\"user\",\"content\":\"Explain React state in simple words\"}]}"
Step 6: Build the React Chat UI
Replace client/src/App.jsx with a complete chat component.
import { useMemo, useRef, useState } from "react";
import "./styles.css";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:5000";
const starterMessages = [
{
role: "assistant",
content: "Hi! Ask me anything about React, JavaScript, APIs, or your project.",
},
];
export default function App() {
const [messages, setMessages] = useState(starterMessages);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef(null);
const canSend = useMemo(
() => input.trim().length > 0 && !isLoading,
[input, isLoading]
);
async function sendMessage(event) {
event.preventDefault();
const text = input.trim();
if (!text || isLoading) return;
const nextMessages = [
...messages,
{ role: "user", content: text },
];
setMessages(nextMessages);
setInput("");
setIsLoading(true);
try {
const response = await fetch(`${API_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Request failed");
}
setMessages([
...nextMessages,
{ role: "assistant", content: data.message },
]);
} catch (error) {
setMessages([
...nextMessages,
{
role: "assistant",
content: "Sorry, I could not answer right now. Please try again.",
},
]);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
}
function clearChat() {
setMessages(starterMessages);
setInput("");
inputRef.current?.focus();
}
return (
<main className="chat-page">
<section className="chat-shell" aria-label="AI chatbot">
<header className="chat-header">
<div>
<p className="eyebrow">React + OpenAI</p>
<h1>AI Chatbot</h1>
</div>
<button type="button" onClick={clearChat} className="ghost-button">
Clear
</button>
</header>
<div className="message-list">
{messages.map((message, index) => (
<article
key={`${message.role}-${index}`}
className={`message ${message.role}`}
>
<div className="avatar">
{message.role === "assistant" ? "AI" : "You"}
</div>
<p>{message.content}</p>
</article>
))}
{isLoading && (
<article className="message assistant">
<div className="avatar">AI</div>
<p className="typing">Thinking...</p>
</article>
)}
</div>
<form className="chat-form" onSubmit={sendMessage}>
<label className="sr-only" htmlFor="chat-input">
Message
</label>
<textarea
id="chat-input"
ref={inputRef}
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask a question..."
rows={2}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage(event);
}
}}
/>
<button type="submit" disabled={!canSend}>
{isLoading ? "Sending" : "Send"}
</button>
</form>
</section>
</main>
);
}
Create client/.env so React knows where your backend runs:
VITE_API_URL=http://localhost:5000
Step 7: Add Chatbot Styling
Replace client/src/styles.css with a clean responsive chat layout.
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #eef2f7;
color: #111827;
}
.chat-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.chat-shell {
width: min(920px, 100%);
height: min(760px, calc(100vh - 48px));
display: grid;
grid-template-rows: auto 1fr auto;
background: #ffffff;
border: 1px solid #dbe3ef;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.14);
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
border-bottom: 1px solid #e5e7eb;
background: #0f172a;
color: #ffffff;
}
.eyebrow {
margin: 0 0 4px;
color: #38bdf8;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.chat-header h1 {
margin: 0;
font-size: 1.35rem;
}
.ghost-button {
border: 1px solid rgba(255, 255, 255, 0.24);
background: transparent;
color: #ffffff;
border-radius: 10px;
padding: 9px 14px;
cursor: pointer;
}
.message-list {
overflow-y: auto;
padding: 22px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.message.user {
grid-template-columns: minmax(0, 1fr) 42px;
}
.message.user .avatar {
grid-column: 2;
background: #2563eb;
}
.message.user p {
grid-column: 1;
grid-row: 1;
justify-self: end;
background: #2563eb;
color: #ffffff;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
background: #10b981;
color: #ffffff;
font-size: 0.76rem;
font-weight: 800;
}
.message p {
max-width: 680px;
margin: 0;
padding: 13px 15px;
border-radius: 14px;
background: #f3f6fb;
color: #111827;
line-height: 1.6;
white-space: pre-wrap;
}
.typing {
color: #64748b;
}
.chat-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
padding: 16px;
border-top: 1px solid #e5e7eb;
background: #f8fafc;
}
.chat-form textarea {
resize: none;
border: 1px solid #cbd5e1;
border-radius: 12px;
padding: 12px 14px;
font: inherit;
outline: none;
}
.chat-form textarea:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18);
}
.chat-form button {
border: 0;
border-radius: 12px;
padding: 0 20px;
background: #0ea5e9;
color: #ffffff;
font-weight: 800;
cursor: pointer;
}
.chat-form button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 640px) {
.chat-page {
padding: 0;
}
.chat-shell {
height: 100vh;
border-radius: 0;
}
.chat-form {
grid-template-columns: 1fr;
}
.chat-form button {
min-height: 44px;
}
}
Step 8: Understand Message History and Context
A chatbot needs context to answer follow-up questions. In the basic example, React sends the recent message history with every request. The backend trims it to the last 12 messages to control cost and latency.
This is enough for a beginner project. For a larger app, you have three common options:
- Client-side short history: easiest, but the chat resets on refresh unless stored.
- Database-backed sessions: store messages per user and conversation ID.
- Retrieval-augmented context: use embeddings or file search to add relevant docs, FAQs, or knowledge base snippets.
Do not send unlimited history. Long prompts increase cost, slow down responses, and may include stale instructions. Summarize older conversation turns or store important facts separately.
Step 9: Add Streaming Responses
Streaming makes the chatbot feel faster because users see the answer appear token by token. The exact SDK helpers can evolve, but the backend idea stays the same: request a stream from OpenAI, forward chunks to the browser, and update the assistant message as chunks arrive.
Add a new streaming endpoint in server/src/index.js:
app.post("/api/chat/stream", async (req, res) => {
try {
const { messages } = req.body;
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: "messages array is required" });
}
const safeMessages = messages.slice(-12).map((message) => ({
role: message.role === "assistant" ? "assistant" : "user",
content: String(message.content || "").slice(0, 4000),
}));
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const stream = await openai.responses.create({
model,
instructions: "You are a helpful AI tutor. Be clear, practical, and concise.",
input: safeMessages,
stream: true,
});
for await (const event of stream) {
if (event.type === "response.output_text.delta") {
res.write(event.delta);
}
}
res.end();
} catch (error) {
console.error("Streaming chat API error:", error);
if (!res.headersSent) {
return res.status(500).json({ error: "Unable to stream response." });
}
res.end();
}
});
Then replace the frontend request logic with a streaming version:
async function sendMessage(event) {
event.preventDefault();
const text = input.trim();
if (!text || isLoading) return;
const nextMessages = [...messages, { role: "user", content: text }];
const assistantIndex = nextMessages.length;
setMessages([...nextMessages, { role: "assistant", content: "" }]);
setInput("");
setIsLoading(true);
try {
const response = await fetch(`${API_URL}/api/chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages }),
});
if (!response.ok || !response.body) {
throw new Error("Streaming request failed");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
fullText += decoder.decode(value, { stream: true });
setMessages((current) =>
current.map((message, index) =>
index === assistantIndex
? { ...message, content: fullText }
: message
)
);
}
} catch (error) {
setMessages([
...nextMessages,
{ role: "assistant", content: "Sorry, streaming failed. Please try again." },
]);
} finally {
setIsLoading(false);
}
}
Streaming is optional, but it is one of the biggest perceived-quality upgrades for chat interfaces.
Step 10: Design the Chatbot Prompt
The instructions field controls how your assistant behaves. Keep it clear and specific. Avoid stuffing too many rules into one prompt.
const instructions = `
You are a helpful AI tutor for Tutorials Logic.
Audience: beginner and intermediate developers.
Style:
- Explain concepts simply.
- Use examples when useful.
- Keep answers practical.
- If the user asks for code, include runnable code.
- If you are unsure, say what information is missing.
Safety:
- Do not ask for secrets, passwords, API keys, or private data.
- Do not invent APIs. Suggest checking official docs for version-specific details.
`;
For a customer support bot, the instructions might include refund policies and escalation rules. For a coding tutor, include teaching style and preferred stack. For an internal company bot, include boundaries around private data and source citations.
Step 11: Security Checklist
- Never expose the OpenAI API key in React. Keep it in backend environment variables.
- Validate request shape. Ensure
messagesis an array and content is a string. - Limit message length. Prevent huge payloads with
express.json({ limit: "1mb" })and string slicing. - Restrict CORS. Use your actual frontend domain in production.
- Add rate limiting. Protect your API from abuse and unexpected costs.
- Log carefully. Avoid storing sensitive prompts, API keys, tokens, or private user data.
- Use authentication for private bots. Public demo bots can be abused if there is no rate limit or quota.
- Handle model errors gracefully. Do not show raw stack traces to users.
A simple Express rate limiter can stop accidental spam:
npm install express-rate-limit
import rateLimit from "express-rate-limit";
const chatLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
});
app.use("/api/chat", chatLimiter);
Step 12: Deployment Notes
Frontend deployment
Build the React app:
cd client
npm run build
Deploy client/dist to Vercel, Netlify, Cloudflare Pages, Hostinger static hosting, or any static host. Set:
VITE_API_URL=https://your-backend-domain.com
Backend deployment
Deploy the Express backend to Render, Railway, Fly.io, a VPS, Docker, or any Node-compatible host. Set these production environment variables:
OPENAI_API_KEY=sk-your-production-key
OPENAI_MODEL=gpt-5.4-mini
PORT=5000
CLIENT_ORIGIN=https://your-frontend-domain.com
Production checklist
- Use HTTPS for frontend and backend.
- Set CORS to the real frontend domain, not
*. - Enable request logging and uptime monitoring.
- Add rate limits and usage quotas.
- Keep API keys in host environment settings, never in Git.
- Test the deployed frontend with the deployed backend URL.
Troubleshooting Common Errors
| Problem | Likely Cause | Fix |
|---|---|---|
| 401 Unauthorized | Missing or wrong API key | Check OPENAI_API_KEY, restart backend, verify key in platform dashboard. |
| 429 Rate limit | Too many requests or quota issue | Add rate limiting, reduce retries, check project limits and billing. |
| CORS error | Frontend origin not allowed | Set CLIENT_ORIGIN to the exact frontend URL. |
| React shows empty answer | Backend response shape mismatch | Confirm backend returns { "message": "..." }. |
| Streaming never appears | Proxy buffers response or wrong event handling | Test locally first, disable buffering on host, verify stream event names. |
| Slow responses | Large prompt/history or expensive model | Trim history, summarize older messages, use a smaller model. |
| API key exposed in browser | OpenAI call placed in React | Move OpenAI call to backend immediately and rotate the exposed key. |
Next Improvements
- Markdown rendering: use a safe markdown renderer for code blocks and lists.
- Conversation IDs: store chats in a database by user and conversation.
- Authentication: require login before using paid AI features.
- File uploads: let users ask questions about uploaded PDFs or docs.
- Knowledge base: add retrieval so the bot can answer from your tutorials or company docs.
- Feedback buttons: collect thumbs up/down to improve prompts and detect bad answers.
- Admin analytics: track total chats, common questions, errors, latency, and estimated cost.
- Safety layer: add moderation or custom rules for public-facing bots.
Official References
Use official docs when upgrading models, SDK versions, or API parameters:
FAQs
1. Can I call OpenAI directly from React?
2. Which OpenAI API should I use for a chatbot?
3. Which model should I start with?
gpt-5.4-mini, then test quality and upgrade if needed.4. Do I need a database?
5. Why is streaming useful?
6. How do I reduce cost?
7. How do I make the chatbot answer from my website content?
8. Can I deploy frontend and backend separately?
VITE_API_URL and CORS correctly.