Making of

AI chat with zero client-side JavaScript

nojs.chat is a full AI chat — streamed answers, formatted Markdown, multi-turn conversations — that ships not one line of JavaScript to the browser. This page is the story of why it exists and exactly how it works. You are reading it on a page with no scripts either; feel free to check.

It started with a Kindle

I read on a Kindle at night. It is a wonderful, deliberately limited device: it does books, and almost nothing else. But reading produces small questions — when was this author born, what does that nautical term mean, how does the thing this chapter describes actually work — and I had no way to ask them without picking up the bright, noisy rectangle the Kindle was supposed to replace. There is no AI app for the Kindle, and every AI chat website falls over in its browser, because they are all JavaScript applications wearing a website costume.

The Kindle's "experimental" browser does technically execute some JavaScript. I decided to ignore that and aim at a stricter target: an AI chat where the browser executes none at all. Partly because partial JS support is quicksand — you never learn which API is missing until it breaks mid-conversation — and partly because the harder constraint is more interesting. And it pays for itself: if the app works with zero JavaScript, the Kindle stops being a special case. Text browsers, decade-old phones, locked-down corporate machines, Tor Browser with scripts disabled, screen readers — they all come along for free.

So the constraint became the product, and the interesting question became: how much of a modern AI chat experience survives it? The answer turned out to be: almost all of it.

The rules

Everything below follows from four principles, in priority order. When they conflict, the higher one wins.

  1. No client-side JavaScript in the active product.
  2. Basic HTML and conservative CSS for ebook-reader-class compatibility.
  3. Static whenever possible, streamed whenever dynamic.
  4. Accessible document and form behavior before visual flourish.

Request flow

  1. Static pageVercel serves public/index.html from static infrastructure.
  2. Form postThe browser submits new_message to /c with no fetch, WebSocket, or client runtime.
  3. ValidationThe route checks body size, message size, signed state, prompt budget, and theme.
  4. Early HTMLThe function starts a text/html stream and sends the document shell before waiting for model text.
  5. Provider streamOpenRouter or DeepSeek returns text chunks through an OpenAI-compatible streaming API.
  6. Safe renderingThe server converts streamed Markdown into allowlisted HTML and escapes raw model HTML.
  7. Next turnThe full conversation is trimmed, signed, and embedded as hidden state in the next form.

Streaming without JavaScript

The part everyone assumes requires JavaScript is streaming: watching the answer appear while the model writes it. It does not. It requires the oldest trick the browser knows — progressive HTML rendering.

Browsers have painted HTML as it arrives over the network since the dial-up era, when a page could take thirty seconds to download and would have been unusable otherwise. That machinery never went away; modern web development just stopped exercising it, because a bundle-rendered app has nothing to show until its scripts arrive. A chunked text/html response still renders incrementally in essentially every browser ever shipped, e-ink included.

So the chat endpoint answers an ordinary form POST like this: it immediately flushes the document head, the stylesheet, the conversation so far, and your new message — before it has contacted the model at all. The connection stays open. As the model streams tokens back, the server converts them to HTML and keeps writing into the same response body, and the browser draws each fragment as it lands. When the model finishes, the server writes the form for your next message — with the freshly signed conversation state inside it — and closes the document. There is no API payload that a client turns into pixels. The response is the page, being written in front of you.

Two production details matter. First, the internet is full of things that buffer: the route sends Cache-Control: no-transform and X-Accel-Buffering: no to ask proxies to pass chunks through untouched. Second, streamed HTML is strictly append-only. You can never revise something already sent — no removing a spinner, no patching an earlier paragraph. Every page has to be designed so that appending is always enough, which is a genuinely clarifying design constraint.

Rendering Markdown you cannot trust, one character at a time

Models write Markdown and browsers need HTML, and that conversion has two problems at once. The first is safety: model output is untrusted input. A prompt can trivially convince a model to emit a script tag, so nothing the model produces may reach the browser as markup. The second is timing: streamed chunks do not align with Markdown structure. A bold phrase can arrive as **bo in one chunk and ld** in the next; a heading marker can arrive before its own space does. You cannot run a normal Markdown library over fragments like that.

Buffering the whole response would solve both problems and destroy the streaming. So the renderer is a small state machine that consumes the stream one character at a time. At the start of each line it withholds output for the few characters where the line is still ambiguous — a # might be a heading or a sentence about hashtags, a 1 might open a numbered list or the year 1975 — and commits as soon as the prefix resolves. Inside a block it escapes every character of model text; the only tags in the output are ones the server itself decided to write, from a short allowlist: paragraphs, headings, lists, inline and fenced code, bold, italic, and links that must start with http.

The system prompt does politely ask the model for tidy Markdown, but safety never depends on its cooperation. If a model — or someone puppeteering it — emits raw HTML, it renders as visible text, which is the correct punishment. And as a backstop, the site's Content-Security-Policy is default-src 'none': even if a script tag somehow appeared in the document, the browser would refuse to run it.

A conversation with no database

Multi-turn chat implies memory, and this server has none: no accounts, no sessions, no cookies, no stored conversations. So where does the history live? In the page you are looking at. Each response ends with a form, and inside that form is a hidden field carrying the entire conversation: JSON, base64url-encoded, with an HMAC-SHA256 signature. When you send your next message, the history travels with it, and the server verifies — in constant time — that the blob is one it signed earlier. Edited or forged history is rejected; the server trusts the state only because it can prove it wrote it.

The consequences are all deliberate. The server can be restarted, redeployed, or scaled to zero without losing anything, because the app keeps no server-side conversation copy. The history lives in the page and travels with each request, including to the configured AI provider when it generates an answer. Closing the page leaves no account history in nojs.chat, although browser history, saved pages, and the provider's own data policy may retain copies. And because every response is a complete document, you can save it, print it, or resubmit an earlier page's form to fork the conversation from that point.

The costs are real but bounded. The state rides along with every request, so the server enforces hard limits — on message size, message count, total prompt budget, and the size of the signed blob itself — and silently trims the oldest messages when a conversation outgrows them. And signed is not encrypted: the state is readable in your own page source. It is your conversation; you are allowed to see it.

Yes, it really works on the Kindle

Not emulated, not hoped-for: on my actual Kindle, in the experimental browser, you type a question on the e-ink keyboard, press Send, and watch the answer stream progressively onto e-paper. Of everything in the project, that first streamed answer on a device designed to do nothing of the sort was easily the most satisfying moment.

The visual design leans into the destination rather than fighting it: a monochrome document layout, monospaced type, solid borders, no animation, and nothing that depends on hover states or precise tapping. E-ink rewards pages that behave like paper, and a chat transcript is, conveniently, already a document.

The Kindle is the origin story, not the boundary. The same pages work in text browsers, on old phones, and anywhere JavaScript is disabled by policy or by choice — which was the point of the constraint all along.

Decisions and tradeoffs

Decision Why Tradeoff
No client-side app JavaScript The core experience works in old browsers, text browsers, locked-down browsers, screen readers, and JavaScript-disabled environments. No optimistic client UI, no local persistence, and no client Markdown rendering.
Static public pages The homepage and this page are generated at build time into public/, so they do not need a function invocation. Changing copy requires a deploy, which is fine for project documentation.
Node.js route for /c The chat route needs request body parsing, provider calls, cancellation, and a streamed HTML response. It has function latency and provider latency; only the public pages are fully static.
Signed hidden state The app avoids a conversation database while still rejecting tampered history. State increases form size, so the server trims history and enforces byte limits.
Markdown in, safe HTML out Models are good at Markdown, and the server owns the only HTML that reaches the browser. Some block formatting appears only once enough Markdown has streamed to render it safely.
Strict CSP The browser should not execute scripts or model-provided markup. Browser-side performance probes must be reintroduced deliberately, temporarily, and removed again.
Direct provider APIs OpenAI-compatible APIs keep the app small and make provider changes cheap. The app must handle provider-specific finish reasons, timeouts, and model limits itself.

Vercel shape

The public pages are build artifacts. The chat endpoint runs on the Node.js runtime because its response is a long-lived stream tied to an upstream model request. A small catch-all route returns CDN-cacheable no-JavaScript pages for unknown addresses.

What is intentionally absent

No account system, no browser storage, no database-backed chat history, no analytics script, no client bundle, and no trusted model HTML.

How to prove streaming

Do not take the streaming claim on faith. Use /c?perf=1 or the local streaming probe. The useful comparison is initial_html_sent versus model_first_chunk. If initial HTML is sent first, the page is not waiting for the full model answer.

npm run measure:streaming -- 'http://localhost:3000/c?perf=1' 'Write a short list about streaming'

Try it

That is the whole trick, really: chunked HTML, a careful sanitizer, signed forms, and the discovery that the browser's HTML parser was a streaming UI framework thirty years before streaming UI frameworks. None of this is new technology — that is what I like about it.

Ask it something small — ideally from the strangest browser you own.