Build a complete React chatbot with a secure Node.js 24 LTS backend and the OpenAI Responses API. The July 2026 notes cover GPT-5.6 model selection, streaming, conversation state, validation, safety, cost controls, deployment, and verification.
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
Let npm record the installed dependency versions, then configure ES modules and the two scripts. This avoids copying stale package pins from an article.
npm install express cors dotenv openai@latest
npm install --save-dev nodemon
npm pkg set type=module
npm pkg set scripts.dev="nodemon src/index.js"
npm pkg set scripts.start="node src/index.js"
Commit the generated package-lock.json and use npm ci in CI and production so the tested dependency graph is reproduced.
Create server/.env. Do not commit this file.
OPENAI_API_KEY=sk-your-api-key-here
OPENAI_MODEL=gpt-5.6-terra
PORT=5000
CLIENT_ORIGIN=http://localhost:5173
Create server/.env.example for teammates or deployment notes.
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.6-terra
PORT=5000
CLIENT_ORIGIN=http://localhost:5173
July 2026 model note: OpenAI's current guidance says gpt-5.6 routes to flagship gpt-5.6-sol. Start a general production chatbot with gpt-5.6-terra for strong quality at a lower price, or evaluate gpt-5.6-luna for efficient high-volume traffic. Select with representative quality, safety, latency, and cost tests rather than model names alone.
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.6-terra";
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" }));
const isValidChatMessage = (message) =>
message &&
typeof message === "object" &&
["user", "assistant"].includes(message.role) &&
typeof message.content === "string" &&
message.content.trim() !== "";
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 ||
messages.some((message) => !isValidChatMessage(message))
) {
return res.status(400).json({ error: "messages must contain valid user or assistant text" });
}
const safeMessages = messages.slice(-12).map((message) => ({
role: message.role,
content: message.content.trim().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, choose a state strategy deliberately:
previous_response_id to continue from the prior response without rebuilding every turn in the browser. Send your instructions again on each request because prior instructions are not automatically carried into the chained response.Whichever option you choose, authorize every conversation lookup on the server. A user must never be able to read another user's conversation by changing an ID. Do not send unlimited history: long prompts increase cost and latency and may retain stale context. Summarize older turns or store durable 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 ||
messages.some((message) => !isValidChatMessage(message))
) {
return res.status(400).json({ error: "messages must contain valid user or assistant text" });
}
const safeMessages = messages.slice(-12).map((message) => ({
role: message.role,
content: message.content.trim().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,
});
// Register this before app.post("/api/chat", ...) and the stream route.
app.use("/api/chat", chatLimiter);
Express applies middleware in registration order, so register the limiter before both chat routes. The default memory store is suitable only for a local demo or one server process. For multiple instances, use a shared store such as Redis and key limits by authenticated user as well as IP address.
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.6-terra
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 unsuitable model tier | Trim history, summarize older messages, and benchmark GPT-5.6 Terra or Luna against your quality target. |
| API key exposed in browser | OpenAI call placed in React | Move OpenAI call to backend immediately and rotate the exposed key. |
gpt-5.6-terra for a strong quality-to-cost balance, then evaluate gpt-5.6-luna for efficient high-volume traffic or gpt-5.6 when the hardest requests justify flagship capability. Decide with representative quality, safety, latency, and cost tests.VITE_API_URL and CORS correctly.Explore 500+ free tutorials across 20+ languages and frameworks.