VuThanhThien®
LinkedIn

© 2026 Vu Thanh Thien. All rights reserved.

GitHubLinkedInFacebookEmail
Back to Blog
May 5, 2026
6 min read

How I Built an Embeddable Chat Widget Script (Like Intercom)

Next.jsReactCloudflare WorkersWidgetViteSystem Design

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 to public/widget.js with Vite (pnpm widget:build). Chat API is a Cloudflare Worker + D1 (not Next /api/chat / Pinecone). Visual data-icon* / data-color / data-showAnimation attrs 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:

LayerRole
widget/ → public/widget.jsLoader, knight launcher, iframe shell, mobile redirect
/chat-widgetSlim React chat UI (useChat + prompt-kit)
workers/portfolio-chatPOST /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
FileResponsibility
widget/main.tsBoot, double-inject guard
widget/config.tsFunctional data-* → config
widget/assets.tsAbsolute knight sheet URLs from script.src origin
widget/embed-shell.tsIframe, bubbles, theme sync, mobile redirect, open/close
widget/launcher.tsxReact: page-mascot + ✕ when open
vite.widget.config.mtsLib 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 launcher
  • data-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: none when closed — avoids covering the launcher on mobile Safari after a scale(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 (+ optional theme=)

Same React UI, full-viewport layout (100dvh), proper safe areas.

The iframe app: /chat-widget

  • src/app/chat-widget/page.tsx — reads isMobile / theme search params
  • src/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:

  1. Next.js — pnpm dev (serves /chat-widget + static /widget.js)
  2. Worker — pnpm chat:dev → http://127.0.0.1:8787

Point the embed at local:

  • Home script: src="/widget.js" (or http://localhost:3000/widget.js)
  • widget/config.ts widgetHost: http://localhost:3000/chat-widget while 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

EndpointRole
GET /healthLiveness
GET /history?sessionId=&limit=&before=Cursor pagination from D1
POST /chatValidate body, rate-limit, persist user msg, stream gpt-4o-mini, persist assistant

What happens on each POST /chat

  1. CORS + validate — allowlisted origins; { sessionId, messages }; size + per-session rate limit.
  2. Persist user message — last user turn → D1.
  3. 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 v4 streamText → toDataStreamResponse().
  4. Persist assistant — onFinish writes 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; use display: none when closed.
  • Avatar styles belong on AvatarFallback — coloring the Radix root does nothing if fallback paints bg-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:

  1. Vite embed (widget/ → widget.js) — knight launcher + iframe shell
  2. Next /chat-widget — React chat UI
  3. 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