A Web Chat UI
Table of Contents
- What We Are Building
- Run the Backend First
- The Page: index.html
- State: The History Array
- Never Use innerHTML for Model Output
- Reading the Stream in the Browser
- Sending a Message
- The Stop Button
- Errors the User Can See
- Keyboard, Accessibility and Mobile
- Recipe: The public Folder
- What I Verified, and What I Did Not
- Deploying It
- Checklist Before You Share It
- Ideas and What Comes Next
- Try It Yourself
- Quick Review: Common Questions
1. What We Are Building
Seven chapters ago a chatbot was a curl command. Now it is time for
a page people can open. By the end of this chapter you will have a small
web chat that:
- shows your messages and the model's reply as it is being written,
- keeps the conversation and resends it with every message (the stateless rule from chapter 3),
- has a Stop button that really cancels the request,
- shows errors in plain words,
- works with the keyboard and on a phone,
- and never touches your API key.
The last point decides the whole design. The browser must not call
the model provider directly, because anything in browser code can be read
by anyone. In API Keys and a Backend
Proxy
you built src/server.js: a small Node server that holds the key, checks
every request, and forwards it. The page you build now talks only to that
server:
browser (index.html + chat.js) --POST /api/chat--> your proxy --key--> LLM API
<--- streamed text --- <---
This chapter does not reprint the server. Read chapter 6 for the details. Here you need to know just three facts about it:
- It serves the files in
public/(so our page and the API share one address, and no CORS setup is needed). POST /api/chattakes{ "messages": [...], "stream": true }, where each message has arole(userorassistant) andcontent. The server chooses the model and adds its own instructions. The browser cannot change them.- With
stream: trueit passes the model's server-sent events straight through, the same format you parsed in Streaming Responses. When something fails, it answers with a status code and a short JSON body such as{ "error": { "message": "..." } }with a safe message.
Our page also reads errors defensively (it accepts { "error": "text" }
too), so a slightly different error body in your own server still shows
something sensible.
You will write two files: public/index.html (structure and a little
CSS) and public/chat.js (behavior). No framework, no build step, no
dependencies.
2. Run the Backend First
Start the mock LLM in one terminal and the proxy in another. Keep using your own port numbers if 8787 or 3000 is busy.
# terminal 1: the pretend LLM provider
node mock-llm.js
# mock LLM listening on http://localhost:8787 (api key: test-key)
# terminal 2: your proxy. Defaults point at the mock, as in chapter 6.
node src/server.js
Now test the proxy without a browser, with curl. The -N flag
turns off curl's own buffering, so you can watch the events arrive:
curl -sN -X POST http://localhost:3000/api/chat \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","content":"my name is Sok"}],"stream":true}'
// output (first lines; ids and the number of events vary)
// data: {"id":"mock-1","choices":[{"index":0,"delta":{"role":"assistant"}}]}
//
// data: {"id":"mock-1","choices":[{"index":0,"delta":{"content":"You "}}]}
//
// ...
The data: lines, blank-line separators and the delta.content field
are the stable part. The id counts up with every request the mock sees.
If you get something like this, the proxy is ready. If you get an error,
fix it here first, because debugging a half-working backend through a
browser is much harder.
One more quick check that proves the point of chapter 6. Search the files the browser will download for your key:
curl -s http://localhost:3000/chat.js http://localhost:3000/ | grep -c "test-key"
# 0
0 matches. The key lives only in the server's environment. Run the same
check against your own page whenever you change it.
3. The Page: index.html
Here is the whole page. It is plain HTML with a short stylesheet:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>chatlab</title>
<style>
:root { color-scheme: light dark; --bg: #f7f7f8; --fg: #1b1b1f; --card: #ffffff;
--me: #1d4ed8; --me-fg: #ffffff; --line: #d4d4d8; --err: #b91c1c; }
@media (prefers-color-scheme: dark) {
:root { --bg: #111113; --fg: #ececf1; --card: #1c1c20; --me: #3b82f6;
--me-fg: #0b0b0d; --line: #3f3f46; --err: #f87171; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.5 system-ui, sans-serif; }
.app { display: flex; flex-direction: column; height: 100dvh;
max-width: 720px; margin: 0 auto; }
h1 { font-size: 1.1rem; margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--line); }
#log { flex: 1; overflow-y: auto; padding: 16px; display: flex;
flex-direction: column; gap: 10px; }
.msg { max-width: 85%; padding: 8px 12px; border-radius: 12px;
background: var(--card); border: 1px solid var(--line);
white-space: pre-wrap; overflow-wrap: anywhere; }
.msg.user { align-self: flex-end; background: var(--me); color: var(--me-fg); border-color: var(--me); }
.msg.error { border-color: var(--err); color: var(--err); }
form { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }
textarea { flex: 1; resize: none; font: inherit; padding: 8px 10px;
border-radius: 8px; border: 1px solid var(--line);
background: var(--card); color: inherit; }
button { font: inherit; padding: 8px 14px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--line); background: var(--card); color: inherit; }
button.primary { background: var(--me); color: var(--me-fg); border-color: var(--me); }
button:disabled { opacity: .5; cursor: not-allowed; }
:focus-visible { outline: 3px solid var(--me); outline-offset: 2px; }
[hidden] { display: none !important; }
#status { padding: 0 16px 8px; min-height: 1.5em; font-size: .875rem; opacity: .75; }
</style>
</head>
<body>
<div class="app">
<h1>chatlab</h1>
<main id="log" role="log" aria-live="polite" aria-label="Conversation" tabindex="0"></main>
<p id="status" role="status"></p>
<form id="form">
<label for="input" class="sr" style="position:absolute;left:-9999px">Message</label>
<textarea id="input" rows="2" placeholder="Type a message. Enter to send, Shift+Enter for a new line." autocomplete="off" required></textarea>
<button id="send" class="primary" type="submit">Send</button>
<button id="stop" type="button" hidden>Stop</button>
</form>
</div>
<script type="module" src="/chat.js"></script>
</body>
</html>
Walk through the decisions, because each one is there for a reason:
<meta name="viewport" ...>makes the page fit a phone screen. Without it, phones pretend to be a wide desktop and shrink everything.- CSS variables and
prefers-color-schemegive a light and a dark theme for free, following the user's system setting. height: 100dvhmakes the app fill the visible screen, even when a phone's address bar shows or hides. The message list (#log) scrolls by itself, while the title and the form stay in place.role="log"andaria-live="polite"tell screen readers that#logis a stream of new messages, to be announced politely when they arrive. The status line hasrole="status"for "Thinking…".<label for="input">gives the textarea a name for assistive technology. We hide it off-screen because the placeholder already shows the idea to sighted users. (A placeholder alone is not a good label, because it disappears when you type.)- Two buttons: Send and Stop. They swap places while a reply is being
written. The
[hidden]rule matters: without[hidden] { display: none !important; }, ourdisplaystyles would override the browser's ownhiddenbehavior. font: inheriton the textarea and16pxbase size are deliberate: iPhones zoom into form fields smaller than 16px, which is annoying.<script type="module">loadschat.jsas a module. Modules run after the page is parsed, so the elements already exist, and variables stay private instead of leaking intowindow.
4. State: The History Array
The top of chat.js collects the page elements and defines the only
state the app needs:
const MAX_MESSAGES = 30; // the server also enforces its own limit
const history = []; // [{ role: "user" | "assistant", content }]
let controller = null;
history is the same array of messages you used in Conversation. It
holds only user and assistant turns. There is no system message
in the browser, because the server adds it. That is a safety feature: a
visitor cannot rewrite the bot's instructions by editing the request in
DevTools. (They can send any user and assistant text they like, so
the server must treat all of it as untrusted. That is why chapter 6
validates it.)
Every send does the same three things: push the user message, send the
whole history (the last MAX_MESSAGES entries), and push the
assistant's reply when it is complete. That is chapter 3 again. The model
has no memory, and the page is the thing remembering.
Why keep only the last 30? Cost and limits. Remember the budget lesson of
chapter 7: a long history resends all the old text every turn. And the
server must still cap the length itself. The browser's limit is a
courtesy, not security, since anyone can bypass your JavaScript and call
/api/chat directly with curl.
The history lives in memory only. Reload the page and the conversation is
gone. To keep it, save the array to localStorage after each reply and
load it on start (Exercise 3). Be careful: it may contain private text.
controller will hold the current request's AbortController, or
null when nothing is running. It doubles as the "busy" flag.
5. Never Use innerHTML for Model Output
Here is the most important line in the file:
function addMessage(role, text = "") {
const el = document.createElement("div");
el.className = `msg ${role}`;
el.textContent = text; // textContent, never innerHTML: model output is untrusted
if (role === "error") el.setAttribute("role", "alert");
log.append(el);
log.scrollTop = log.scrollHeight;
return el;
}
textContent puts the text on the page as text. innerHTML would
parse it as HTML, and that is dangerous when the text comes from a
model. Why? A model repeats and transforms what it reads. If a user, a
web page, or a document the model was shown contains
<img src=x onerror=...>, a model may output it. If you inject that
with innerHTML, the browser runs the script in your page, in the
context of your site. That is cross-site scripting (XSS). It is also a
form of prompt injection (the model was tricked by text inside its
input) turning into a real browser attack.
Model output is untrusted input, like any text a stranger typed. This is the rule for the rest of your career with LLMs: treat it as data, never as code or markup.
We tested exactly this. The mock model echoes what you say, so typing an HTML snippet makes it send the snippet back:
you type: <img src=x onerror=alert(1)> <b>bold</b>
mock replies: You said: <img src=x onerror=alert(1)> <b>bold</b>
In the real page the reply showed up as literal characters. A count of
<img> and <b> elements inside the log came out as 0 and 0, and no
alert appeared. With innerHTML it would have been different.
What if you want formatted answers (bold, lists, code blocks), like the
big chat apps? Convert Markdown to HTML with a well-maintained library
and pass the result through an HTML sanitizer library before you put
it in the page. Do not write your own sanitizer, and never skip it. For a
first version, plain text with white-space: pre-wrap (which we have in
the CSS) keeps line breaks and is safe.
Finally, setAttribute("role", "alert") on error messages makes screen
readers announce them immediately.
6. Reading the Stream in the Browser
In Node you read the response body with for await. In the browser the
portable tool is a reader: res.body.getReader(). Each reader.read()
gives { value, done }, where value is a Uint8Array of bytes.
You already know the two traps from chapter 4: a chunk can end in the
middle of an event, and (new to you, but important for Khmer, Chinese,
emoji and more) a chunk can end in the middle of a character, because
one character can be several bytes. The fix is the same as in src/stream.js
plus one option:
// Turn the raw response stream into complete SSE "data:" payloads.
async function* sseData(reader) {
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let end;
while ((end = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
for (const line of event.split("\n")) {
if (line.startsWith("data:")) yield line.slice(5).trim();
}
}
}
}
The two key ideas:
{ stream: true }tells the decoder "more bytes may follow". If a chunk ends halfway through a multi-byte character, the decoder keeps the loose bytes and finishes the character on the next call. Without it you would get broken�characters.- The
buffercollects text until a blank line (\n\n) marks a complete event. Only then do we yield it.
We can test this in Node with a fake reader that cuts the bytes into
7-byte pieces, which splits events, lines and even a Khmer word in awkward
places. This is the same sseData function, with no browser needed:
// fake-reader-test.js (paste the sseData function above this line)
const wire = new TextEncoder().encode(
'data: {"delta":"សួស្តី"}\n\ndata: {"delta":" world"}\n\ndata: [DONE]\n\n');
const chunks = [];
for (let i = 0; i < wire.length; i += 7) chunks.push(wire.slice(i, i + 7));
let n = 0;
const reader = { read: async () => n < chunks.length ? { value: chunks[n++], done: false } : { done: true } };
console.log("chunks:", chunks.length);
for await (const data of sseData(reader)) console.log(data);
// output
// chunks: 12
// {"delta":"សួស្តី"}
// {"delta":" world"}
// [DONE]
All stable: it is deterministic text and fixed chunk sizes. Twelve awkward pieces came out as three clean events, and the Khmer word arrived intact. (The word, "សួស្តី", is a Khmer greeting. Each Khmer character takes three bytes in UTF-8, so a 7-byte cut will land inside one.)
Note the difference from chapter 4: there, res.body was used directly
with for await in Node. Browsers have supported that for fewer years,
and getReader() works everywhere, so the page uses the reader.
7. Sending a Message
Now the heart of the page, send(text). We will read it in four pieces.
Piece 1: update the screen and the state.
async function send(text) {
history.push({ role: "user", content: text });
const userBubble = addMessage("user", text);
const bubble = addMessage("assistant", "");
controller = new AbortController();
setBusy(true);
let reply = "";
We store the user's message, draw it (keeping a handle on that bubble
in userBubble), and create an empty assistant bubble that we will
fill word by word. Then we make the abort controller and lock the form
with setBusy(true):
function setBusy(busy) {
input.disabled = busy;
sendBtn.disabled = busy;
sendBtn.hidden = busy;
stopBtn.hidden = !busy;
statusEl.textContent = busy ? "Thinking…" : "";
if (busy) stopBtn.focus(); else input.focus();
}
Why disable the form while waiting? Imagine two messages sent before
the first reply finishes. Replies would interleave in one bubble, and the
history would have two user messages in a row. Disabling the input (and
guarding with if (!text || controller) return; in the submit handler)
keeps one request at a time. It also prevents accidental double-clicks
that would double your bill.
The focus() calls matter for keyboard users. When a reply starts,
focus moves to Stop (it is the thing you might want next). When it ends,
focus returns to the text box, so you can type the next message without
touching the mouse.
Piece 2: the request.
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: history.slice(-MAX_MESSAGES), stream: true }),
signal: controller.signal,
});
if (!res.ok) throw new Error(await errorText(res));
Notice what is not here: no Authorization header, no model name, no
key. The path /api/chat is relative, so it goes to whatever address
served the page. And signal connects the request to our abort
controller.
Piece 3: the stream.
for await (const data of sseData(res.body.getReader())) {
if (data === "[DONE]") break;
const piece = JSON.parse(data).choices?.[0]?.delta?.content;
if (piece) {
reply += piece;
bubble.textContent = reply;
log.scrollTop = log.scrollHeight;
}
}
if (!reply) throw new Error("The reply was empty. Please try again.");
history.push({ role: "assistant", content: reply });
Each event carries a small delta. The first has only a role, the last
has none, so piece is often undefined, and we skip those. We append
the piece to reply, then set the bubble's text to the whole reply so
far. (Setting textContent each time is simple and fast enough for
chat-sized text.) Only when the stream ends do we add the assistant
message to history.
The page reads choices[0].delta.content because that is the shape the
mock and this series use. A different provider may put text in another
field. This is the one place to change when you switch (the same note as
in chapter 2).
Piece 4: the cleanup, in catch and finally. We cover it in
sections 8 and 9, because it is where Stop and errors are handled.
8. The Stop Button
Generating can take a while, and sometimes you can see the answer is going the wrong way. A Stop button is a basic courtesy, and it saves money, since a cancelled request stops the model from producing more tokens (usually; check your provider).
The wiring is one line:
stopBtn.addEventListener("click", () => controller?.abort());
Calling abort() makes the pending fetch or the pending
reader.read() throw an AbortError. It also closes the connection to
your proxy, and a well-written proxy then cancels its upstream request
too (the proxy from chapter 6 does this when the browser disconnects). In
the catch block we treat it as a normal outcome, not a failure:
} catch (err) {
const stopped = err.name === "AbortError";
if (reply) {
// Keep what we have, so the history still alternates user/assistant.
history.push({ role: "assistant", content: reply });
bubble.textContent = reply + (stopped ? " [stopped]" : " [interrupted]");
} else {
// Nothing came back: take the question back out and let the user retry.
bubble.remove();
userBubble.remove();
history.pop();
input.value = text;
if (!stopped) addMessage("error", err.message || "Could not reach the server.");
}
} finally {
controller = null;
setBusy(false);
}
There are two cases after a stop or a failure:
- Some words arrived. Keep them. Mark the bubble
[stopped](the user asked) or[interrupted](something broke), and save the partial reply inhistory. We must, because otherwise the history would end with a user message followed by another user message on the next turn, which many APIs reject. - Nothing arrived. Remove the empty bubble and the user's bubble, remove the question from the history, and put the text back in the box so the user can try again without retyping (and without a duplicate in the log). If it was a real error, show it.
In our test with the mock, the reply "You said: one two three four ..."
was stopped after about 200 milliseconds and the page showed
You said: one two [stopped]: the two words that had arrived, plus the
marker. The exact words that arrive before a Stop depend on timing, so
only the idea is stable.
9. Errors the User Can See
Errors come from three places, and the page has to handle each one:
- The server answers with an error status. The
if (!res.ok)line turns it into a message using a small helper:
async function errorText(res) {
try {
const body = await res.json();
if (typeof body.error === "string") return body.error;
if (body.error?.message) return body.error.message;
} catch {}
return `Something went wrong (${res.status}).`;
}
It accepts both { "error": "text" } and { "error": { "message": "text" } }, and falls back to a generic line with the status if the
body is not JSON (for example an HTML error page from a hosting
company). Because the server sends only safe messages (chapter 6), it
is fine to show the text. Even so, the text is still displayed with
textContent.
2. The network fails (server down, Wi-Fi gone). fetch rejects with a
TypeError, and we show err.message or the fallback "Could not reach
the server."
3. The stream breaks halfway. The for await throws after some words.
That is the [interrupted] case from section 8.
We tested the second case for real: with the mock stopped, the proxy answered with a short JSON error and status 502, and the page showed
error: The AI service is not reachable right now.
with the error bubble marked as an alert, the question put back in the text box (and gone from the log), and the form enabled again. The message is the one chapter 6's proxy sends, so yours shows whatever your server sends. Another real case: a 2,500-character message is refused by the proxy's 2,000-character rule, and the page showed "A message can have at most 2000 characters." the same way.
Remember chapter 7's lesson here too: show people calm, short messages, not status codes or stack traces. The retry logic of chapter 7 belongs on the server side, where it can retry before the browser sees a failure. In the browser, a retry is the user pressing Send again. Do not auto-retry a stream that already printed words.
10. Keyboard, Accessibility and Mobile
Chat is a typing activity, so the keyboard must feel right:
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text || controller) return;
input.value = "";
send(text);
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
form.requestSubmit();
}
});
- Use a real
<form>. A form gives you a Send button that works with Enter, touch and assistive technology, and the browser handles the details. We callevent.preventDefault()so the browser does not reload the page. - Enter sends, Shift+Enter adds a line.
form.requestSubmit()runs the normal submit path (including the browser'srequiredcheck). !event.isComposingmatters if your users type in languages that use an input method editor (IME), such as Chinese or Japanese, or Khmer on some keyboards. While someone is composing a character, Enter confirms the candidate and must not send the message. Skipping this check is a very common bug, and it feels awful for those users.trim()and the empty check stop blank messages.- Focus is managed: Stop gets focus while generating; the textarea gets it back afterwards.
- Colors with enough contrast and a visible
:focus-visibleoutline help keyboard and low-vision users. Always test by pressing Tab through the page.
For mobile, the details are in the HTML and CSS: the viewport meta tag,
100dvh, a 16px font in the input, buttons large enough to tap, and
overflow-wrap: anywhere so a long word or link cannot push the page
wider than the screen. In my test at a phone-sized width of 375 pixels,
the page was exactly 375 pixels wide (no sideways scrolling) and the
input row sat at the bottom edge of the screen.
11. Recipe: The public Folder
Your project now has two new files. The structure after this chapter:
chatlab/
mock-llm.js
src/ config.js client.js stream.js conversation.js
cli.js retry.js budget.js errors.js server.js
public/ index.html chat.js
public/index.html is the file from section 3. public/chat.js is the
whole script:
const log = document.querySelector("#log");
const form = document.querySelector("#form");
const input = document.querySelector("#input");
const sendBtn = document.querySelector("#send");
const stopBtn = document.querySelector("#stop");
const statusEl = document.querySelector("#status");
const MAX_MESSAGES = 30; // the server also enforces its own limit
const history = []; // [{ role: "user" | "assistant", content }]
let controller = null;
function addMessage(role, text = "") {
const el = document.createElement("div");
el.className = `msg ${role}`;
el.textContent = text; // textContent, never innerHTML: model output is untrusted
if (role === "error") el.setAttribute("role", "alert");
log.append(el);
log.scrollTop = log.scrollHeight;
return el;
}
function setBusy(busy) {
input.disabled = busy;
sendBtn.disabled = busy;
sendBtn.hidden = busy;
stopBtn.hidden = !busy;
statusEl.textContent = busy ? "Thinking…" : "";
if (busy) stopBtn.focus(); else input.focus();
}
// Turn the raw response stream into complete SSE "data:" payloads.
async function* sseData(reader) {
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let end;
while ((end = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
for (const line of event.split("\n")) {
if (line.startsWith("data:")) yield line.slice(5).trim();
}
}
}
}
async function errorText(res) {
try {
const body = await res.json();
if (typeof body.error === "string") return body.error;
if (body.error?.message) return body.error.message;
} catch {}
return `Something went wrong (${res.status}).`;
}
async function send(text) {
history.push({ role: "user", content: text });
const userBubble = addMessage("user", text);
const bubble = addMessage("assistant", "");
controller = new AbortController();
setBusy(true);
let reply = "";
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: history.slice(-MAX_MESSAGES), stream: true }),
signal: controller.signal,
});
if (!res.ok) throw new Error(await errorText(res));
for await (const data of sseData(res.body.getReader())) {
if (data === "[DONE]") break;
const piece = JSON.parse(data).choices?.[0]?.delta?.content;
if (piece) {
reply += piece;
bubble.textContent = reply;
log.scrollTop = log.scrollHeight;
}
}
if (!reply) throw new Error("The reply was empty. Please try again.");
history.push({ role: "assistant", content: reply });
} catch (err) {
const stopped = err.name === "AbortError";
if (reply) {
// Keep what we have, so the history still alternates user/assistant.
history.push({ role: "assistant", content: reply });
bubble.textContent = reply + (stopped ? " [stopped]" : " [interrupted]");
} else {
// Nothing came back: take the question back out and let the user retry.
bubble.remove();
userBubble.remove();
history.pop();
input.value = text;
if (!stopped) addMessage("error", err.message || "Could not reach the server.");
}
} finally {
controller = null;
setBusy(false);
}
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text || controller) return;
input.value = "";
send(text);
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
form.requestSubmit();
}
});
stopBtn.addEventListener("click", () => controller?.abort());
| Piece | Role |
|---|---|
history | Array of user/assistant messages, resent on every send |
addMessage(role, text) | Draws a bubble with textContent only |
setBusy(busy) | Locks the form, swaps Send and Stop, moves focus |
sseData(reader) | Chunks of bytes in, complete data: payloads out |
errorText(res) | Safe error message from a failed response |
send(text) | One full turn: request, stream, history, cleanup |
controller | AbortController for Stop; null when idle |
Open http://localhost:3000/ and chat. With the mock, type "my name is
Sok", then "what is my name?" You get "Your name is Sok." because the page
resent the history, which is chapter 3 working in a browser.
12. What I Verified, and What I Did Not
You should know what was actually tested. I ran the mock LLM and the proxy code from chapter 6, loaded this exact page in a real browser, and drove it with a script that filled the box and submitted the form. Then I read the page's contents. These checks passed:
| Check | Result |
|---|---|
| Reply streams in word by word | Mid-stream, the bubble held "Hello! How can " and later the full reply |
| Form locks while waiting | Input disabled, Send hidden, Stop shown, "Thinking…" in the status line |
| Form unlocks afterwards | Input enabled, Stop hidden |
| History is resent | After "my name is Sok" and "what is my name?" the reply was "Your name is Sok." |
| Stop works | Reply cut to "You said: one two [stopped]", form usable again |
| Model output is not parsed as HTML | HTML typed in came back as text; 0 <img> and 0 <b> elements created |
| Enter key sends | A synthetic Enter keypress in the box sent the message |
| Server error shown | With the mock stopped: visible error, alert role, question restored in the box |
| Server rule shown | A 2,500-character message: the proxy's message appeared as an error |
| Phone width | 375 px wide, no horizontal scroll, input row at the bottom |
| Key not in served files | grep -c test-key found 0 matches |
| Stream parsing across chunk edges | The Node test above, with Khmer text split mid-character |
What I did not verify: other browsers (I used one), real touch screens, screen reader announcements, behavior with a real provider (the mock is canned, and real replies are longer and slower, so look at scroll and timing yourself), slow or lossy networks, and the proxy hosted on a real server. Test those before you rely on them. Automated browser tests (for example with Playwright) are worth learning when your UI grows.
13. Deploying It
On your laptop, the app works. To share it, it must run on a computer
that stays on. The important fact from chapter 6 again: this is not a
static site. A static host (like the one that serves this website) only
sends files, so it cannot hold your key or run /api/chat. You need
somewhere that runs your Node code. The main options:
- A small Node host or app platform. You upload the project, the
platform runs
node src/server.js, and gives you a URL. Simple, and your code is the same as on your laptop. Many have a free or cheap tier, but check the terms, limits and whether the app sleeps when idle. - A virtual private server (VPS). A rented Linux machine. More control, more work: you install Node, keep the process running, update the system and manage HTTPS.
- A container. Package the app with Docker and run it anywhere that runs containers. If you are new to this, start with Docker Sandbox Crash Course.
- Serverless functions. Keep the static page on a static host and
turn
/api/chatinto a function that runs on demand. The key lives in the function's environment. Watch two things: streaming support differs by platform, and per-request limits (time, memory) can cut off long replies. Read the platform's docs for both.
Whichever you choose, these rules do not change:
- Environment variables for secrets. Put
LLM_API_KEYin the host's secret or environment settings, not in a file in your project. Your code reads it from the environment, asconfig.jsalready does. - Never commit
.env. Make sure.gitignorelists it before your first commit. If a key was ever committed, treat it as leaked: revoke it and make a new one (chapter 6). - Use HTTPS. Most hosts provide it for free. Without HTTPS, anyone on the same network can read your users' messages.
- Set the provider's spending limit before the site goes public.
- Keep your server's rate limit and body limit. A public URL will be found and tried.
- Pin your Node version (the
enginesfield inpackage.jsonor the host's setting) so the host runs what you tested.
14. Checklist Before You Share It
Do not send the link to a group chat yet. Go through this list. It is the difference between a demo and something you can responsibly leave on the internet.
- Authentication. Who is allowed to use it? An open
/api/chatmeans strangers can spend your money. Even a simple shared login or invitation code is far better than nothing. (Chapter 6 covers sessions and tokens.) - Rate limits. Per user and per IP, not only global. Cap message
length, history length and
max_tokens. - Spend cap. A hard monthly limit at the provider, an alert at, say, 50 percent, and a budget in code (chapter 7). Check the usage page during the first days.
- Logs and monitoring. Log facts, not content (chapter 7). Know how you will find out the bot is down or abused.
- Privacy notice. Tell people what is sent to the model provider, what you store, and for how long. Do not collect more than you need. If your users are students or children, think carefully about their data and about school or local rules.
- Content and safety. Models can produce wrong, rude or unsafe answers (see Why Models Hallucinate). Say clearly that answers can be wrong, give the bot a narrow job in the system prompt, and decide what happens when someone asks about medical, legal, self-harm or other sensitive topics. Many providers offer a moderation or safety service. Consider it for public bots.
- Prompt injection. Anything the model reads can contain instructions to it. With a plain chat that is mostly an annoyance. The risk grows when the bot can use tools or read files, so run such code in a sandbox (see Docker Sandbox Crash Course).
- An off switch. Know how to disable the bot fast: change the key, turn off the route, or take down the app.
- A human way out. For a support bot, always give a way to reach a person.
If you cannot tick most of these, keep the bot private, or share it only with a few trusted people who know it is a prototype.
15. Ideas and What Comes Next
You now have every part of a real chat product, in miniature: API client, memory, streaming, a backend that guards the key, reliability and cost control, and a UI. Where you take it is up to you. A few ideas, from easy to bigger:
- A school helper. A bot with a system prompt that explains homework topics step by step and refuses to just give answers. Test it with students and teachers.
- A Telegram or Facebook Messenger bot. The chat is someone else's
app. Your server receives a message through a webhook, calls your
chat()function and sends the reply back. ReuseConversation(keyed by chat ID),withRetryandBudget. - A support bot for a small shop or club. A system prompt with opening hours, prices and rules, and a clear "ask a human" button. Remember that the model may invent facts, so keep the answers close to a short, correct text you provide.
- A language practice partner. The system prompt sets the level and the language, and the bot corrects mistakes.
- Better memory. Summarize old turns instead of dropping them (chapter 3 and the earlier series).
The big next step is to let the bot use your own documents instead of only what the model knew in training. That is RAG, retrieval-augmented generation: find the few relevant passages first, put them in the prompt, and answer from them. After that comes agents with tools and MCP, where the model can ask your code to do things such as search, calculate or look up records. Those two series are planned and are not published yet.
In the meantime, go back and strengthen the foundation: How LLMs Work (for Developers) explains the model's side of everything you just built, and the Docker Sandbox Crash Course will help you run code that touches AI output safely.
16. Try It Yourself
- Make it yours. Change the page title, colors and header. Add a small note under the form: "Answers may be wrong." Check the page in dark mode.
- A Clear button. Add a "New chat" button that empties
historyand the#logelement. Where should its focus go afterwards? - Remember across reloads. Save
historytolocalStorageafter each completed reply, and rebuild the bubbles on page load. What privacy question does this raise? Add a button that erases it. - Show usage. The last stream event carries a
usageobject. Read it insend()and show "tokens used this chat" in the status line. Add up the totals across turns. - Retry button. When an
[interrupted]or error message appears, add a "Try again" button that resends the last user message. Make sure you do not create two user messages in a row inhistory. - Break it on purpose. In DevTools, try to (a) send a 100,000
character message with
fetch, (b) send 1,000 messages inmessages, (c) add asystemmessage. What does your server do in each case? If something goes through, fix the server, not the page.
17. Quick Review: Common Questions
Questions beginners often ask about this chapter. Try answering each one yourself first, then click the question to open the answer.
Why can't the browser call the AI provider directly?
Because it would need your API key, and anything the browser has can be read by anyone who opens DevTools or "view source". They could copy the key and spend your money. A small backend that holds the key and checks every request keeps the secret where users cannot see it.
Why does the reply appear word by word?
Because the server streams it. The model produces a few tokens at a time, and the page draws each piece as it arrives instead of waiting for the full answer. The total time is similar, but the first words appear much sooner, so it feels faster.
Is it really dangerous to put model output in innerHTML?
Yes. A model can output anything it is shown or tricked into saying,
including HTML with script. innerHTML would run it inside your page, in
your site's name. textContent shows it as plain text and cannot run
anything. If you need formatting, use a Markdown library plus an HTML
sanitizer.
Can I host this on a static site like GitHub Pages?
The page, yes. The chat, no, not by itself. A static host cannot run your
/api/chat code or hold a secret. Host the proxy somewhere that runs Node
or a serverless function, and point the page's fetch at it. Then you may
also need to set up CORS, because the page and the API are on different
addresses.
What happens when two people use the bot at the same time?
Each browser keeps its own history and sends it with every request, and
the server does not remember anyone between requests, so conversations do
not mix. The shared things are your rate limit, your key's quota and your
bill. That is why per-user limits and a spend cap matter.
How do I stop people from abusing a public bot?
Several layers: require sign-in or an invitation code, rate-limit by user
and by IP, cap message and history size, cap max_tokens, set a spending
limit at the provider, and watch usage. No single layer is enough, and
none of them belongs only in the browser, because the browser is the
attacker's side.
Do I need a framework like React for this?
No. This page is about 170 lines with no build step, and it is easy to understand and host. A framework helps when the interface grows: many screens, saved chats, shared components. The ideas stay the same in any framework: keep a messages array, stream into a state variable, render text safely, cancel with an abort controller.
Where to go next. You have built a complete chatbot, from the first
curl to a streaming web page, and you know how to protect it. Two series
build on it and are planned: RAG: Chat with Your Own Documents (give
the bot knowledge it was never trained on), then Agents with Tools &
MCP (let it act). Until they arrive, deepen your skills with Docker
Sandbox Crash Course, and revisit
How LLMs Work (for Developers) to see the
model from the inside.
Buy Me a Coffee
Found this tutorial helpful? Consider buying me a coffee to support more free content!