Building a streaming chat UI in Hugo without React or an iframe


Langflow ships an embed widget. For Astra Docs Chat I wanted a full page that matches the rest of jamieede.com: same header, same typography, no iframe chrome. This post covers the Hugo layout and the vanilla JavaScript that streams markdown answers from /api/astra-chat.

Series context: Building Astra Docs Chat

Related: Proxy · Langflow chat flow · DeepSeek swap

The API contract this UI expects is documented in Proxying Langflow from Cloudflare Pages Functions .

Live page: Astra Docs Chat


Langflow’s web component works for internal tools. For a public portfolio site I wanted:

  • Same site header, footer, and theme tokens as /analyzer
  • No iframe sizing quirks or third-party widget CSS
  • Full control over markdown rendering and code block wrapping
  • A single pattern I can copy for the next tool

The trade-off is maintenance: you own the JS. For this site, that was already true on the analyzer page.


content/astra-chat.md          → front matter, url: /astra-chat
layouts/astra-chat/single.html → full-page shell (header/footer from theme)
static/css/astra-chat.css      → chat panel, bubbles, code wrap
static/js/astra-chat.js        → fetch, SSE parse, markdown render
config.toml                    → nav link: "Astra Chat"

content/astra-chat.md sets layout: astra-chat and url: /astra-chat. The HTML template loads marked from jsDelivr and the chat script at the bottom of the page.

The layout mirrors Document Analyzer : one custom Hugo layout, one JS module, no build step beyond Hugo itself.


The shell is intentionally boring:

  • Message thread (#chat-messages, aria-live="polite")
  • Starter prompt buttons (#starters)
  • Form with textarea and Send

Starter prompts match common doc questions:

  • “How do I create a collection in Astra DB?”
  • “What are PCU groups?”
  • “Explain hybrid search in Astra DB Serverless.”

A welcome assistant bubble is injected on load so the page is never an empty box.

CSS adds an astra-chat-shell--active class once the user sends a message or clicks a starter. That tightens vertical space and de-emphasises the starter row after the first turn.

An “How it works” section below the panel links back to the parent technical write-up and mentions 271 pages, the Cloudflare proxy, and DeepSeek + Astra DB.


Langflow accepts a session_id on each run so follow-up questions stay in context. The UI generates a UUID once and stores it in sessionStorage:

const SESSION_KEY = 'astra-chat-session-id';

function getSessionId() {
  let id = sessionStorage.getItem(SESSION_KEY);
  if (!id) {
    id = crypto.randomUUID();
    sessionStorage.setItem(SESSION_KEY, id);
  }
  return id;
}

Refresh the page: same session. Close the tab: new session. There is no server-side history UI in v1; this is lightweight continuity, not a chat archive.

Each POST includes { message, session_id }.


Flow:

  1. Append user bubble (markdown-rendered for consistency)
  2. Disable Send while in flight
  3. Append assistant bubble with blinking cursor ()
  4. fetch('/api/astra-chat', { method: 'POST', ... })
  5. Read response.body as a stream
  6. On each SSE chunk, append text and re-render markdown + cursor
  7. On [DONE] or stream end, remove cursor; re-enable Send

Mid-stream answer on /astra-chat: starter prompts, markdown, and fenced code visible before the stream finishes.

Non-OK responses parse { error } from JSON when possible and show an error-styled bubble: “Message required”, “Request timed out”, or a generic network message.

Enter submits; Shift+Enter inserts a newline in the textarea.


The proxy emits lines like:

data: {"chunk":"partial text"}

data: [DONE]

The client buffers incomplete lines, splits on \n, and handles only lines starting with data: :

if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
const { chunk } = JSON.parse(data);
fullText += chunk;
assistantBubble.innerHTML = renderMarkdown(fullText) +
  '<span class="astra-chat-cursor">▋</span>';

Same pattern as the analyzer’s streaming reader: copy-paste friendly if you add more chat tools to the site.


Re-parsing the full assistant text on every chunk is simple and good enough for doc-style answers. marked runs with { breaks: true } so single newlines render as line breaks.

Trade-off: mid-stream markdown can briefly look wrong until closing fences arrive (e.g. half a code block). In practice, code blocks usually stabilise within a second or two of streaming.

CSS wraps long lines inside code blocks so API samples do not blow out the panel width: overflow-wrap and white-space: pre-wrap on .astra-chat-bubble pre code. Without that, horizontal scroll on narrow viewports breaks the chat panel layout.


Starter buttons use data-prompt attributes and delegate clicks from #starters:

<button type="button" class="astra-chat-starter" data-prompt="What are PCU groups?">
  PCU groups
</button>

Clicking a starter calls the same sendMessage() path as manual input: no special API.


  • No Langflow URL
  • No API keys
  • No WebSocket: HTTP POST + SSE only
  • No client-side retrieval or embedding
  • No source citations panel in v1

All of that stays in Langflow + the Pages Function.


  1. hugo server: page at localhost:1313/astra-chat
  2. Run Pages Functions locally with secrets (wrangler pages dev public) so /api/astra-chat resolves

Without step 2, the UI loads but chat POSTs 404 or fail.

Set Cloudflare secrets the same way as production:

wrangler pages secret put LANGFLOW_URL --project-name jamieedecom
wrangler pages secret put LANGFLOW_API_KEY --project-name jamieedecom

Custom Hugo page Langflow embed
Visual match to site Yes iframe / widget styling
Secrets in browser No (with proxy) Depends on hosting
Markdown control Full (marked, your CSS) Widget defaults
Maintenance You own JS Upstream widget updates

For a portfolio site with an existing analyzer pattern, custom won. The same SSE parsing approach appears on Document Analyzer ; for Worker-side streaming details see Rebuilding the document analyzer on Cloudflare’s full stack .


Series index: Building Astra Docs Chat

Open Astra Docs Chat and watch the Network tab while a message streams: you should see only same-origin /api/astra-chat, never Langflow.

×
Page views: