{"slug": "a-chat-widget-that-runs-on-websites-what-we-had-to-get-right", "title": "A chat widget that runs on websites: what we had to get right", "summary": "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.", "body_md": "I'm Elena, CMO at [Chatim](https://chatim.app/en/). 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.\n\nThe 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.\n\nThis is the whole install:\n\n```\n<script>\n  window.chatim = window.chatim || {};\n  window.chatim.cmd = window.chatim.cmd || [];\n  window.chatim.settings = { projectId: \"YOUR_PROJECT_ID\" };\n</script>\n<script src=\"https://widget.chatim.app/widget.js\" async></script>\n```\n\nThree boring lines, each there for a reason.\n\n`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.\n\nThe settings object comes before the script tag so the config exists the moment our code starts running. No waiting, no polling for it.\n\nThe 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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script).\n\nThe 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.\n\nSo 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](https://developers.google.com/tag-platform/gtagjs), and most analytics SDKs have some version of it.\n\n```\n// Safe to call at any time, loaded or not\ndocument.querySelector(\"#chat-button\").addEventListener(\"click\", () => {\n  window.chatim.cmd.push(() => {\n    window.chatim.widget.open();\n  });\n});\n```\n\nWe 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.\n\nThe full method list (`open`, `close`, `setConfig`, `getConfig`, `setParams`, `restartChat`) and the queue formats are in the [Widget SDK docs](https://chatim.app/en/documentation/widget-sdk/).\n\nSites 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:\n\n`null`. No objects, no arrays, no functions.` projectId`, `demo`, `__proto__`, `constructor` and `prototype` are reserved and ignored.\nThe 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.\n\nParams come from three places and merge in a fixed priority order:\n\n`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.\n\n```\n// After login\nwindow.chatim.widget.setParams({\n  userId: user.id,\n  email: user.email,\n  planType: user.plan\n});\n\n// On logout: clears stored chat history and starts a fresh session.\n// Matters on shared devices, like a PC in a shop or a library.\nwindow.chatim.widget.restartChat();\n```\n\nThe 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](https://nextjs.org/docs/messages/react-hydration-error) caused by a third-party script, you know why we care.\n\nFor Next.js the whole integration is one client component:\n\n``` python\n'use client';\n\nimport { useEffect } from 'react';\nimport Script from 'next/script';\n\nexport default function ChatWidget() {\n  useEffect(() => {\n    window.chatim = window.chatim || {};\n    window.chatim.cmd = window.chatim.cmd || [];\n    window.chatim.settings = { projectId: 'YOUR_PROJECT_ID' };\n  }, []);\n\n  return (\n    <Script\n      src=\"https://widget.chatim.app/widget.js\"\n      strategy=\"lazyOnload\"\n    />\n  );\n}\n```\n\nDrop `<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 loading, so the settings object is always in place before our script executes. The `next/script` [strategy options](https://nextjs.org/docs/app/api-reference/components/script) 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.\n\nOne 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](https://chatim.app/en/documentation/how-to-install-on-nextjs/).\n\nThe 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.\n\nThe 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 [loading third-party JavaScript](https://web.dev/articles/optimizing-content-efficiency-loading-third-party-javascript) is a good checklist for anyone on either side of this, embedding a widget or building one.\n\nWriting this down forced us to list the rough edges, so here they are.\n\n**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.\n\n**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.\n\n**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.", "url": "https://wpnews.pro/news/a-chat-widget-that-runs-on-websites-what-we-had-to-get-right", "canonical_source": "https://dev.to/elena_chatim/a-chat-widget-that-runs-on-websites-what-we-had-to-get-right-5ckc", "published_at": "2026-10-03 04:23:11+00:00", "updated_at": "2026-10-03 04:37:48.832413+00:00", "lang": "en", "topics": ["ai-products", "ai-tools", "developer-tools"], "entities": ["Chatim", "Google Analytics", "MDN"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-chat-widget-that-runs-on-websites-what-we-had-to-get-right", "markdown": "https://wpnews.pro/news/a-chat-widget-that-runs-on-websites-what-we-had-to-get-right.md", "text": "https://wpnews.pro/news/a-chat-widget-that-runs-on-websites-what-we-had-to-get-right.txt", "jsonld": "https://wpnews.pro/news/a-chat-widget-that-runs-on-websites-what-we-had-to-get-right.jsonld"}}