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
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.
The final app has two parts:
| 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.
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.
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.
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.
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.
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.
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.
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\"}]}"
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
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;
}
}
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:
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.
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.
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.
messages is an array and content is a string.express.json({ limit: "1mb" }) and string slicing.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);
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
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
*.| 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. |
Use official docs when upgrading models, SDK versions, or API parameters:
gpt-5.4-mini, then test quality and upgrade if needed.VITE_API_URL and CORS correctly.Explore 500+ free tutorials across 20+ languages and frameworks.