How I Built an Embeddable Chat Widget Script (Like Intercom)
Why build a script-based widget?
I wanted a chat widget I can embed on any page with one <script> tag—similar to Intercom / Tawk / Crisp:
- The host page shouldn’t need React.
- The widget UI should be isolated (styles + layout) and easy to deploy.
- Configuration should be done with
data-*attributes (no build step for hosts). - It should work well on both desktop and mobile.
In this portfolio, the script is loaded on the home page via Next.js next/script:
<Script
id="chat-widget"
src="https://vuthanhthien.com/widget.js"
// src="/widget.js" // same-origin while iterating locally
data-uniqueKey="vuthanhthien"
data-welcomeMessage="Hi! How can we help you?"
/>
Update (2026-10): The launcher is a knight page-mascot (cursor-follow + click reactions). Source lives in
widget/and builds topublic/widget.jswith Vite (pnpm widget:build). Chat API is a Cloudflare Worker + D1 (not Next/api/chat/ Pinecone). Visualdata-icon*/data-color/data-showAnimationattrs are ignored.
High-level architecture
The embed script manipulates DOM and mounts a small React launcher; the real chat UI lives in an iframe pointing at /chat-widget on the Next.js app. Streaming + persistence run on a separate Worker.
Rendering diagram…
Separation of concerns:
| Layer | Role |
|---|---|
widget/ → public/widget.js | Loader, knight launcher, iframe shell, mobile redirect |
/chat-widget | Slim React chat UI (useChat + prompt-kit) |
workers/portfolio-chat | POST /chat, GET /history, CORS, rate limit, D1 |
The embed script: Vite → public/widget.js
Source of truth: TypeScript modules under widget/. Do not hand-edit the bundle.
pnpm widget:build # also runs as prebuild before next build
pnpm widget:dev # watch mode while iterating on the shell
| File | Responsibility |
|---|---|
widget/main.ts | Boot, double-inject guard |
widget/config.ts | Functional data-* → config |
widget/assets.ts | Absolute knight sheet URLs from script.src origin |
widget/embed-shell.ts | Iframe, bubbles, theme sync, mobile redirect, open/close |
widget/launcher.tsx | React: page-mascot + ✕ when open |
vite.widget.config.mts | Lib mode, IIFE → public/widget.js |
Configuration via data-* attributes
The script resolves itself via document.currentScript (fallback: script[src*="widget.js"]):
data-uniqueKey— optional host identifier (snippet / analytics; not required by runtime)data-welcomeMessage— bubble text above the launcherdata-showMessageBubble— force show/hide bubbles (overrides local defaults; mobile defaults off)
Knight sprite sheets load as absolute URLs from the script’s origin (/knight-directions.webp, /knight-reactions.webp) so embeds on other hosts still fetch assets from your site.
Injecting UI
widget.js creates:
- A fixed iframe container (hidden with
display: nonewhen closed — avoids covering the launcher on mobile Safari after ascale(0)trick). - A React-mounted knight launcher — click toggles chat; when open, knight shrinks (96→56px) and a small ✕ closes.
- Optional message bubbles above the launcher (dismissible; remembered in
localStorage).
Desktop: overlay panel above the launcher; body scroll locks while open. Launcher uses position: fixed with safe-area insets and a z-index above the closed panel.
Mobile behavior: full-screen
On mobile UAs the script redirects instead of opening a cramped overlay:
/chat-widget?isMobile=true(+ optionaltheme=)
Same React UI, full-viewport layout (100dvh), proper safe areas.
The iframe app: /chat-widget
src/app/chat-widget/page.tsx— readsisMobile/themesearch paramssrc/components/chat-widget/—ChatWidget, composer, message list, history hooks, theme sync
Theme: use-widget-theme applies ?theme=, parent postMessage({ type: "chat-widget-theme" }), localStorage theme, or prefers-color-scheme. Assistant bubbles stay white; avatars use theme-aware zinc/white fallbacks (styles must target AvatarFallback, not only the root).
sessionId: anonymous continuity
Without login, the widget stores:
localStorage.sessionId = crypto.randomUUID()
Legacy creatorUID keys are migrated automatically. Every chat request sends sessionId; on mount the UI loads:
GET {WORKER}/history?sessionId=...
Communication and lifecycle
Embed shell ↔ iframe:
- Open/close:
postMessage({ isWidgetOpen }) - Theme:
postMessage({ type: "chat-widget-theme", theme }) - Language cookie sync from iframe → parent (optional)
Designing for postMessage early makes focus, resize, and theme sync much easier later.
Local development
Two processes:
- Next.js —
pnpm dev(serves/chat-widget+ static/widget.js) - Worker —
pnpm chat:dev→http://127.0.0.1:8787
Point the embed at local:
- Home script:
src="/widget.js"(orhttp://localhost:3000/widget.js) widget/config.tswidgetHost:http://localhost:3000/chat-widgetwhile iterating- Env:
NEXT_PUBLIC_CHAT_API_URL=http://127.0.0.1:8787
Rebuild the bundle after shell/launcher changes: pnpm widget:build.
The backend: Cloudflare Worker + D1
There is no Next /api/chat. The UI uses:
useChat({
api: chatApiUrl("/chat"), // NEXT_PUBLIC_CHAT_API_URL + "/chat"
body: { sessionId },
})
Worker package: workers/portfolio-chat
Prod example: https://portfolio-chat.thudan-td.workers.dev
| Endpoint | Role |
|---|---|
GET /health | Liveness |
GET /history?sessionId=&limit=&before= | Cursor pagination from D1 |
POST /chat | Validate body, rate-limit, persist user msg, stream gpt-4o-mini, persist assistant |
What happens on each POST /chat
- CORS + validate — allowlisted origins;
{ sessionId, messages }; size + per-session rate limit. - Persist user message — last user turn → D1.
- Stream reply — system prompt = profile + structured experience (
workers/portfolio-chat/src/experience.ts, kept in sync with site data by hand) + recent D1 turns; AI SDK v4streamText→toDataStreamResponse(). - Persist assistant —
onFinishwrites the reply to D1 (best-effort).
D1 schema (conceptually)
messages(id, session_id, role, content, created_at)
index (session_id, created_at)
Fresh store — no Pinecone migration. Env: Worker secret OPENAI_API_KEY; Next needs NEXT_PUBLIC_CHAT_API_URL.
Hard-earned lessons
- Isolation wins — iframe avoids CSS collisions; the UI is a normal Next route.
- Don’t load the iframe until first open (desktop) — keeps the host page light.
- Treat mobile as a separate UX — full-screen redirect beats a tiny overlay.
- Closed ≠
scale(0)only — a full-viewport scaled layer can still steal hit-testing; usedisplay: nonewhen closed. - Avatar styles belong on
AvatarFallback— coloring the Radix root does nothing if fallback paintsbg-muted. data-*config scales well — easy for hosts and docs; ignore unused visual attrs explicitly.- Separate the API from the marketing app — Worker + D1 scales and deploys independently of Vercel.
Summary
Two layers, three runtimes:
- Vite embed (
widget/→widget.js) — knight launcher + iframe shell - Next
/chat-widget— React chat UI - Cloudflare Worker + D1 — stream + history
If you build your own: script = loader + shell, iframe page = real app, API = own origin with CORS.
Written by Vu Thanh Thien