Making of
AI chat with zero client-side JavaScript
nojs.chat streams formatted answers and carries a conversation from one plain HTML form to the next. The browser receives no JavaScript.
It started with a Kindle
I read on a Kindle at night. Sometimes I want to look up an author or a nautical term without picking up my phone. Client-side JavaScript made the usual AI chat design a poor fit for the Kindle browser.
The Kindle's "experimental" browser can run some JavaScript, but I wanted a chat that needed none. Partial JS support makes it hard to know which missing API will break a conversation. Plain HTML forms also give text browsers, older phones, locked-down browsers, and assistive technology a useful starting point. Each still needs its own compatibility checks.
The rules
The project follows four principles, in priority order. When they conflict, the higher one wins.
- No client-side JavaScript in the active product.
- Keep pages lean and fast, even on very slow connections. Minimize downloads and latency; serve static pages where possible and stream answers as they arrive.
- Basic HTML and conservative CSS for ebook-reader-class compatibility.
- Accessible document and form behavior before visual flourish.
Compatibility and accessibility remain constraints when optimizing performance.
Request flow
- Static pageVercel serves
public/index.htmlfrom static infrastructure. - Form postThe browser submits
new_messageto/cwith no fetch, WebSocket, or client runtime. - ValidationThe route checks body size, message size, signed state, prompt budget, and the optional light/dark setting.
- Early HTMLThe function starts a
text/htmlstream and sends the document shell before waiting for model text. - Provider streamOpenRouter or DeepSeek returns text chunks through an OpenAI-compatible streaming API.
- Safe renderingThe server converts streamed Markdown into allowlisted HTML and escapes raw model HTML.
- Next turnThe full conversation is trimmed, signed, and embedded as hidden state in the next form.
Streaming without JavaScript
Browsers can display HTML as it arrives. That is how nojs.chat streams answers. A slow connection can delay the text. The browser may also wait for more text before showing it, and servers between you and the site can delay forwarding it.
After validating the form POST, the server sends the document head, stylesheet link, conversation so far, and new question. It keeps the connection open and appends safe HTML as the model produces text. At the end, it writes the next form with signed history and closes the document.
Streamed HTML is append-only: the server cannot remove a spinner or patch an earlier paragraph after sending it. The page therefore uses elements that can remain in place as more HTML arrives.
After a form submission, the URL fragment handles scrolling. The form posts to /c#latest-message, and the server writes an anchor with that name immediately above the newest question. The anchor is part of the first flush, before model text. The follow-up field has no autofocus, because focusing a control parsed at the end of the response could move the reader away from the answer.
The server also adds a two-kilobyte HTML comment before the answer, intended to help browsers start displaying the page sooner. This is not a verified Safari fix: WebKit reports describe cases where HTML comments do not trigger display.
The server sends the empty answer area and its AI label first, then adds text there as the model replies.
Once streaming has begun, the status code has already been sent. If the provider fails mid-answer, the server closes open Markdown blocks, appends a generic error notice, and writes a complete form with new signed state when the connection remains available. The saved history retains the partial answer within the conversation limits.
Rendering Markdown safely
Model output is untrusted: it can contain script tags, so it must not reach the browser as markup. Streamed chunks also 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. Parsing each fragment separately would miss that formatting.
The renderer tracks Markdown structure one character at a time. It briefly holds ambiguous line prefixes: # could start a heading or a sentence about hashtags; 1 could start a list or the year 1975. Once the prefix is clear, it emits HTML. It escapes model text and writes only allowlisted tags: paragraphs, headings, lists, inline and fenced code, bold, italic, and links that must start with http.
The system prompt asks for tidy Markdown, but safety does not depend on that request. Raw model HTML renders as visible text. The Content-Security-Policy denies everything by default and adds narrow exceptions for the site stylesheet, its forms, and the anonymous analytics image. It allows no scripts, so a browser would refuse to execute one even if it reached the document.
A conversation with no database
The server keeps no account, session, cookie, or stored conversation. Each completed response ends with a form containing bounded conversation history: JSON, base64url-encoded, with an HMAC-SHA256 signature. The history returns with the next question, and the server uses a constant-time comparison to verify that it signed the value. Edited or forged state is rejected.
The history stays in the page, so it survives server restarts and scale-to-zero. It travels with each request, including to the configured AI provider. Closing the page leaves no account history in nojs.chat, although browser history, saved pages, and the provider's data policy may retain copies. A completed page can also be saved, printed, or resubmitted to continue from that point.
Signed hidden state adds to each request, so the server limits message size, message count, total prompt size, and the signed blob itself. It keeps the most recent exchanges together and displays a notice when conversation text must be omitted or shortened. Signed is not encrypted: the state is readable in your own page source.
The Kindle and other browsers
The original version streamed an answer in the experimental browser on my Kindle: I typed on the e-ink keyboard, pressed Send, and watched text appear on the page. That test motivated the project. It is not current compatibility evidence for every later revision, Kindle model, or firmware.
The page uses a monochrome layout, monospaced type, solid borders, no animation, and no control that depends on hover or precise tapping.
The wider target includes text browsers, older phones, and browsers with JavaScript disabled. A compatible device must reach the site over HTTPS, submit a form, display the response, and continue the conversation. Current automated checks cover JavaScript-disabled Chromium at a narrow viewport and real form posts through w3m. They do not establish current Kindle firmware, e-ink refresh, interactive text-browser, or screen-reader behavior.
Decisions and tradeoffs
| Decision | Why | Tradeoff |
|---|---|---|
| No client-side app JavaScript | The core experience uses document navigation and forms, reducing the browser features it requires. | No optimistic client UI, browser storage API, local persistence, client Markdown rendering, or analytics script. |
| Anonymous traffic counts | Production pages load a one-pixel image from GoatCounter. It counts approximate visits without JavaScript, cookies, or stored prompts. | It adds one small request and cannot reliably distinguish every person or disguised bot. |
| 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. |
| 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. |
Hosting on Vercel
The homepage, this article, and the 404 document are generated into public/ for Vercel's static infrastructure. The only function is api/c.ts, reached at /c. It delegates to a framework-independent Web Request/Response handler; a small Node.js HTTP adapter runs the same handler locally. If you leave the page while an answer is being generated, Vercel can detect the closed connection and tell the server to cancel its request to the AI provider. There is no Next.js or React runtime.
Try it
Ask it a question — ideally from the strangest browser you own.