cd /news/ai-products/a-chat-widget-that-runs-on-websites-… · home › topics › ai-products › article
[ARTICLE · art-144280] src=dev.to ↗ pub= topic=ai-products verified=true sentiment=· neutral

A chat widget that runs on websites: what we had to get right

Chatim, a live chat and AI chatbot widget provider, detailed the engineering constraints behind its embeddable website widget, including a defensive install snippet, a command queue modeled on Google Analytics' dataLayer, and a deliberately flat custom-params schema. The company said the snippet must tolerate duplicate pastes, async loading, and untrusted host environments, and that once code is embedded in thousands of footers it becomes a permanent API.

by read6 min views3 publishedOct 3, 2026

I'm Elena, CMO at Chatim. We make a live chat and AI chatbot widget for small business websites, and this is our first post on DEV. I handle marketing, so the engineering below is our team's work. I'm the one who asked the annoying questions and wrote the answers down.

The thing about an embeddable widget is that it runs on sites you don't control. You don't know the framework, the CSS, the tag manager, or how many times someone will paste your snippet. The host site always comes first. Here is what that meant for us in practice, with code.

This is the whole install:

<script>
  window.chatim = window.chatim || {};
  window.chatim.cmd = window.chatim.cmd || [];
  window.chatim.settings = { projectId: "YOUR_PROJECT_ID" };
</script>
<script src="https://widget.chatim.app/widget.js" async></script>

Three boring lines, each there for a reason.

window.chatim = window.chatim || {} exists because the snippet gets pasted twice more often than you would think. A theme footer plus a Google Tag Manager tag is the classic case. "Multiple widgets appearing" has its own section in our troubleshooting docs for a reason. Reusing the existing object means the second copy doesn't wipe out settings the first one already set.

The settings object comes before the script tag so the config exists the moment our code starts running. No waiting, no polling for it.

The script is async, not defer, because the widget has no dependency on anything else on the page and should not hold up anything either. The browser fetches it in parallel and runs it whenever it arrives. MDN has a good explanation of the difference in its script element reference.

The downside of async is that you never know when window.chatim.widget will exist. If a developer wires a "Chat with us" button to window.chatim.widget.open() and a visitor clicks it one second after page load, on a slow connection that call throws.

So the snippet creates window.chatim.cmd, a plain array. Anything pushed into it before the widget loads is executed once the widget boots, in order. Anything pushed after boot runs immediately. Same pattern Google Analytics uses with dataLayer.push in gtag.js, and most analytics SDKs have some version of it.

// Safe to call at any time, loaded or not
document.querySelector("#chat-button").addEventListener("click", () => {
  window.chatim.cmd.push(() => {
    window.chatim.widget.open();
  });
});

We accept three formats in the queue: a function (the one we recommend), an array like ['widget.open'], and an object with method and args. The array form is the legacy one from our first SDK. It stays because it is already sitting in footers we cannot update. That is the recurring theme of this post: once something is pasted into a thousand footers, it is an API forever.

The full method list (open, close, setConfig, getConfig, setParams, restartChat) and the queue formats are in the Widget SDK docs.

Sites pass visitor context to the widget so the person answering the chat knows who they are talking to: user ID, plan, cart value, the campaign that brought them. We call these custom params, and we limited them hard:

null. No objects, no arrays, no functions. projectId, demo, __proto__, constructor and prototype are reserved and ignored. The last line is the one developers ask about. Params are merged into plain objects, and if we accepted arbitrary keys, Flatness is a product decision as much as a technical one. Params are displayed in a visitor panel that a support agent reads in the middle of a conversation. Twenty labeled values are scannable. A nested JSON blob is not. Flat values are also trivial to validate and truncate, which matters when the input is coming from code we have never seen.

Params come from three places and merge in a fixed priority order:

utm_source, utm_medium, utm_campaign, utm_term, utm_content and ref from the page URL without any code.window.chatim.settings.params, set in the snippet. window.chatim.widget.setParams(), called at runtime. Highest priority, and it merges rather than replaces, so you only send what changed.

// After login
window.chatim.widget.setParams({
  userId: user.id,
  email: user.email,
  planType: user.plan
});

// On logout: clears stored chat history and starts a fresh session.
// Matters on shared devices, like a PC in a shop or a library.
window.chatim.widget.restartChat();

The widget touches window and document, and it injects its own DOM after it loads. That is deliberate. Nothing widget-related is ever part of the server-rendered HTML, so there is nothing for React to find a mismatch on during hydration. If you have ever chased a hydration error caused by a third-party script, you know why we care.

For Next.js the whole integration is one client component:

'use client';

import { useEffect } from 'react';
import Script from 'next/script';

export default function ChatWidget() {
  useEffect(() => {
    window.chatim = window.chatim || {};
    window.chatim.cmd = window.chatim.cmd || [];
    window.chatim.settings = { projectId: 'YOUR_PROJECT_ID' };
  }, []);

  return (
    <Script
      src="https://widget.chatim.app/widget.js"
      strategy="lazyOnload"
    />
  );
}

Drop <ChatWidget /> into the root layout and you're done. The ordering works out on its own: useEffect runs after hydration, and lazyOnload loads the script during browser idle time after the page has finished , so the settings object is always in place before our script executes. The next/script strategy options are worth reading if you have never looked at them. lazyOnload is the right default for anything that is not needed for the first paint, and a chat bubble is the definition of not needed for the first paint.

One consequence to keep in mind: with lazyOnload, the script can arrive seconds after a page is interactive. Any "open chat" button in your own UI should go through the command queue from section 2, not call widget.open() directly. The step-by-step version with the layout file is in our Next.js install guide.

The widget is built with Preact and ships as a single script of about 50 KB gzipped at the time of writing. That covers the launcher and the closed state. The open chat UI is loaded when someone actually clicks, because most visitors never do.

The launcher is fixed-position and takes no space in the document flow, so it cannot shift anything. The script loads after the page content. Our internal rule is simple: Lighthouse scores on the host site should not move when the widget is installed. If yours do, we want the URL. The web.dev guide on third-party JavaScript is a good checklist for anyone on either side of this, embedding a widget or building one.

Writing this down forced us to list the rough edges, so here they are.

No ready event. getConfig() returns null until the widget has initialized, and our docs currently suggest polling with setInterval. That is a workaround, not a design. A ready callback or a promise is the obvious fix and it is on the list.

Legacy globals. window.chatimSettings and window.chatimWidget still work because old installs depend on them. Every release has to keep them working. See the theme above about footers and forever.

Script tag only. There is no npm package and no published TypeScript types for window.chatim. Today you declare them yourself. Whether we ship a package depends a lot on whether people want one, which brings me to the questions.

── more in #ai-products 4 stories · sorted by recency
── more on @chatim 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-chat-widget-that-r…] indexed:0 read:6min 2026-10-03 · —