← Blog

Build a Chatbot with Claude API and React

2026-10-18 · 5 min read · SubToAPI Team

Building a chatbot with the Claude API and React comes down to three parts: a backend that holds your API key and talks to Claude, a frontend that renders messages and handles user input, and a streaming layer that connects them so responses appear token by token instead of all at once. This guide walks through each piece with working code, including the backend endpoint, the React hooks for state and streaming, and the deployment details people usually get wrong.

The short answer for anyone in a hurry: never call Claude directly from the browser (your API key would be exposed), stream responses using Server-Sent Events or the fetch streaming API, and keep conversation history as an array of role/content objects that you pass back on every request. Below is a full implementation you can adapt.

Architecture overview

A React chatbot talking to Claude needs:

Skipping the backend and calling Claude straight from the browser means shipping your API key in client-side JavaScript, which anyone can extract from devtools. This is true whether you use Anthropic's API directly or a gateway like SubToAPI — the key always stays server-side.

Step 1: Set up the backend endpoint

Here's a minimal Express route that proxies to an API key you control. If you're using SubToAPI, the request shape is the same as the Claude Messages API, so this code works against https://api.subtoapi.app/v1/messages with a sub_live_... key:

// server.js
import express from "express";
import fetch from "node-fetch";

const app = express();
app.use(express.json());

app.post("/api/chat", async (req, res) => {
  const { messages } = req.body;

  const upstream = await fetch("https://api.subtoapi.app/v1/messages", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      model: "claude-sonnet-4-5",
      max_tokens: 1024,
      stream: true,
      messages
    })
  });

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  upstream.body.pipe(res);
});

app.listen(3001);

Keep the key in an environment variable, never in frontend code. Check /docs/quickstart for the full request/response shape, and /docs/streaming for details on the event format if you want to parse chunks instead of piping the raw stream.

Step 2: Build the React chat state

A chatbot needs one source of truth for the conversation. Use a simple reducer or useState with an array of messages:

import { useState, useRef } from "react";

export function useChat() {
  const [messages, setMessages] = useState([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const controller = useRef(null);

  async function sendMessage(text) {
    const userMsg = { role: "user", content: text };
    const history = [...messages, userMsg];
    setMessages([...history, { role: "assistant", content: "" }]);
    setIsStreaming(true);

    controller.current = new AbortController();

    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages: history }),
      signal: controller.current.signal
    });

    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let acc = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const chunk = decoder.decode(value);

      for (const line of chunk.split("\n")) {
        if (!line.startsWith("data: ")) continue;
        try {
          const event = JSON.parse(line.slice(6));
          if (event.type === "content_block_delta") {
            acc += event.delta.text || "";
            setMessages(prev => {
              const copy = [...prev];
              copy[copy.length - 1] = { role: "assistant", content: acc };
              return copy;
            });
          }
        } catch {}
      }
    }

    setIsStreaming(false);
  }

  return { messages, sendMessage, isStreaming };
}

This parses Server-Sent Events chunk by chunk and updates the last assistant message as text arrives, which gives you the typing effect users expect from a Claude-powered chatbot.

Step 3: Render the chat UI

The component itself stays simple because all the logic lives in the hook:

import { useChat } from "./useChat";
import { useState } from "react";

export default function ChatBot() {
  const { messages, sendMessage, isStreaming } = useChat();
  const [input, setInput] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    if (!input.trim() || isStreaming) return;
    sendMessage(input);
    setInput("");
  }

  return (
    <div className="chat">
      <div className="messages">
        {messages.map((m, i) => (
          <div key={i} className={`msg ${m.role}`}>
            {m.content}
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          disabled={isStreaming}
          placeholder="Ask something..."
        />
        <button type="submit" disabled={isStreaming}>Send</button>
      </form>
    </div>
  );
}

Handling context and message limits

Claude's API is stateless — every request needs the full conversation history. For a chatbot this means:

Adding error handling and retries

Streaming connections drop. Add a basic retry and a visible error state rather than letting the UI hang:

try {
  // fetch + stream logic
} catch (err) {
  setMessages(prev => [...prev, { role: "assistant", content: "Something went wrong. Try again." }]);
  setIsStreaming(false);
}

For production chatbots, also track response latency and token usage per request. SubToAPI's dashboard reports this automatically for each application key, which is useful once you have more than one chatbot or client consuming the same backend — see /pricing for plan details if you're scaling past a side project.

Deploying

Deploy the backend proxy anywhere that supports Node (Vercel functions, a small VPS, Render). The React frontend can be a static build served from the same host or a CDN — it only needs to reach your /api/chat endpoint. Rotate your API key if it's ever exposed in a commit, and rate-limit the endpoint per user to avoid one browser tab burning through your usage.

Questions

Do I need a backend, or can React call Claude directly? You need a backend. Calling Claude directly from the browser exposes your API key in client-side code, which anyone can read via devtools or network inspection.

How do I get the typing/streaming effect in the chat UI? Stream the response as Server-Sent Events from your backend, read it with the Fetch API's ReadableStream in React, and append each delta to the last assistant message as it arrives.

Can I use this same setup with multiple chatbots or apps? Yes — issue a separate API key per application (for example via SubToAPI's per-app keys) so you can track usage and set limits independently, even though the React and streaming code stays identical.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →