Tutorials Logic, IN info@tutorialslogic.com
React + OpenAI Project

Build an AI Chatbot with React and OpenAI: Production Guide

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.

React Chatbot OpenAI API Node.js Backend Streaming UI Production Notes

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.
What You Will Build reference table
LayerTechnologyResponsibility
FrontendReact + ViteDisplay chat UI, collect user input, call backend API
BackendNode.js + ExpressProtect API key, validate requests, call OpenAI
AI APIOpenAI Responses APIGenerate assistant responses from conversation input
Config.envStore API key, model name, allowed origin, port
DeploymentVercel/Netlify + Render/Railway/Fly/Node hostHost 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 24 LTS recommended; Node.js 22 LTS is also supported. Do not start a new deployment on Node.js 20 because it reached end of life in 2026.
  • 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.

Code Example 1
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.

Terminal Command 1
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.

Terminal Command 1
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.

Terminal Command 2
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.

Step 3: Add Environment Variables

Create server/.env. Do not commit this file.

Environment Config 1
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.

Environment Config 2
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.

Step 4: Create the OpenAI Client

Create server/src/openai.js.

Environment Config 1
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.

Step 5: Build a Basic Chat API

Create server/src/index.js. This first version returns the full answer after OpenAI completes generation.

Code Example 1
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:

Terminal Command 2
cd server
npm run dev

Test it with curl or Postman:

Terminal Command 3
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.

Environment Config 1
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:

Environment Config 2
VITE_API_URL=http://localhost:5000

Step 7: Add Chatbot Styling

Replace client/src/styles.css with a clean responsive chat layout.

Code Example 1
* {
  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, choose a state strategy deliberately:

  • Client-side short history: easiest, but the chat resets on refresh unless stored.
  • Database-backed sessions: store messages per user and conversation ID.
  • Responses API chaining: send 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.
  • Conversations API: use a durable conversation object when the same thread must continue across sessions, devices, or jobs.
  • Retrieval-augmented context: use embeddings or file search to add relevant docs, FAQs, or knowledge base snippets.

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.

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:

Code Example 1
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:

Code Example 2
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.

Code Example 1
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 messages is 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.
  • Authenticate and authorize. For saved chats, verify that the signed-in user owns the conversation on every read and write.
  • Add per-user limits. Combine rate limits, daily quotas, model token limits, and budget alerts to control abuse and unexpected costs.
  • Treat external content as untrusted. Retrieved pages, uploads, and tool results may contain prompt injection. Keep them separate from developer instructions and never let their text silently grant permissions.
  • Use least-privilege tools. Allowlist tool actions, validate arguments server-side, and require confirmation for consequential actions such as purchases, deletion, or sending messages.
  • Add input and output safeguards. Use moderation or domain-specific classifiers when appropriate, constrain outputs, and provide human escalation for high-impact workflows.
  • Log carefully. Avoid storing sensitive prompts, API keys, tokens, or private user data.
  • Handle model errors gracefully. Do not show raw stack traces to users.
  • Red-team before launch. Test prompt injection, data extraction, long inputs, repeated requests, unsafe requests, tool misuse, and attempts to cross account boundaries.

A simple Express rate limiter can stop accidental spam:

Terminal Command 1
npm install express-rate-limit
Code Example 2
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.

Step 12: Deployment Notes

Frontend deployment

Build the React app:

Terminal Command 1
cd client
npm run build

Deploy client/dist to Vercel, Netlify, Cloudflare Pages, Hostinger static hosting, or any static host. Set:

Environment Config 2
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:

Environment Config 3
OPENAI_API_KEY=sk-your-production-key
OPENAI_MODEL=gpt-5.6-terra
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

Troubleshooting Common Errors reference table
ProblemLikely CauseFix
401 UnauthorizedMissing or wrong API keyCheck OPENAI_API_KEY, restart backend, verify key in platform dashboard.
429 Rate limitToo many requests or quota issueAdd rate limiting, reduce retries, check project limits and billing.
CORS errorFrontend origin not allowedSet CLIENT_ORIGIN to the exact frontend URL.
React shows empty answerBackend response shape mismatchConfirm backend returns { "message": "..." }.
Streaming never appearsProxy buffers response or wrong event handlingTest locally first, disable buffering on host, verify stream event names.
Slow responsesLarge prompt/history or unsuitable model tierTrim history, summarize older messages, and benchmark GPT-5.6 Terra or Luna against your quality target.
API key exposed in browserOpenAI call placed in ReactMove 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.

FAQs

1. Can I call OpenAI directly from React?
Answer: No. React code runs in the browser, so your API key would be visible. Use a backend route.
2. Which OpenAI API should I use for a chatbot?
Answer: Use the Responses API for modern text generation and multi-turn style interactions.
3. Which model should I start with?
Answer: Start with 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.
4. Do I need a database?
Answer: Not for a demo. Use a database when you want user accounts, saved conversations, analytics, or long-term memory.
5. Why is streaming useful?
Answer: Streaming improves perceived speed because users see the answer as it is generated.
6. How do I reduce cost?
Answer: Trim or summarize history, keep instructions focused, benchmark Terra and Luna, cap output length, cache safe repeated answers, and add per-user quotas.
7. How do I make the chatbot answer from my website content?
Answer: Add retrieval: store your content in a searchable index, fetch relevant snippets, and include them in the model input.
8. Can I deploy frontend and backend separately?
Answer: Yes. Host React as a static app and Express as a Node service. Configure VITE_API_URL and CORS correctly.
9. What if users abuse the chatbot?
Answer: Add authentication, rate limiting, quotas, logging, and abuse monitoring.
10. Should I store every prompt?
Answer: Only if your privacy policy allows it. Avoid storing secrets, sensitive user data, or unnecessary private content.
11. Which Node.js version should I deploy?
Answer: Use Node.js 24 LTS for a new deployment or Node.js 22 LTS when your host or dependencies require it. Node.js 20 reached end of life in 2026.
Browse Free Tutorials

Explore 500+ free tutorials across 20+ languages and frameworks.