I recently built an AI support chatbot for websites: a site owner uploads their FAQ, gets an assistant, and drops one <script> tag on their site. A chat bubble appears in the corner and answers visitors' questions.
The AI part was the easy bit. The hard part was that my code now runs inside websites I don't control. Here are the five things that took the most time to get right.
1. Use Shadow DOM, or the host site's CSS will break you
The first version used a normal <div>. On the very first test site, the host's global styles (button { ... }, * { ... }) turned my widget into a mess.
The fix is to render everything inside a Shadow DOM and reset styles at the root:
const host = document.createElement("div");
host.id = "my-widget";
document.body.appendChild(host);
const root = host.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = ":host{all:initial} *{box-sizing:border-box;font-family:system-ui,sans-serif}";
root.appendChild(style);
:host { all: initial } stops inherited styles from leaking in, and nothing you put inside the shadow root leaks out to the page.
2. Never render the AI's answer as HTML
It's tempting to use innerHTML so the bot can return bold text or links. Don't. The answer is built from text the site owner pasted in and text the visitor typed, and both can contain something like <img src=x onerror=...>.
Every message is created with textContent:
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text; // never innerHTML
return node;
}
You lose rich formatting, but you can't get XSS through the chat. For a support widget that's a good trade.
3. Lock the widget to the sites the owner allows
The widget is identified by a public key in the script tag, so anyone can copy it onto their own site and burn the owner's AI credits. Each assistant therefore has an allow-list of origins, and the API only answers those:
export function corsHeaders(origin: string | null, allowedOrigins: string[]) {
if (!origin || !allowedOrigins.includes(origin)) return null; // no CORS → browser blocks it
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
Vary: "Origin",
};
}
Two details that bit me:
-
Normalise origins when the owner saves them. People paste
https://shop.com/some/page/— storenew URL(value).origin, and only allowhttps:(plainhttp:just for localhost). -
Same-origin requests have no
Originheader. When the widget runs on the app's own marketing page, a GET arrives withoutOriginand gets rejected.Sec-Fetch-Site: same-originis set by the browser and can't be faked by a page, so it's safe to use for that case.
CORS only protects against browsers, so the server still checks the allow-list and rate-limits, which brings me to:
4. Rate-limit the public endpoint per IP
The chat endpoint is public by design, which makes it an easy target. Every message costs real money in AI tokens. Per-IP rate limits on the widget's chat and config endpoints, plus monthly reply limits per plan enforced on the server, keep one bad actor from draining an account.
Also: send no cookies. The widget uses fetch(..., { credentials: "omit" }), because an anonymous widget has no reason to carry the visitor's cookies for your domain.
5. Make it feel native: language and dark mode
Small things made the biggest difference in how "finished" it felt:
-
Language: the widget sends the visitor's
Accept-Language, and the server returns the UI strings (placeholder, "Send", status line) in that language. -
Dark mode: a few extra CSS rules inside
@media (prefers-color-scheme: dark)in the shadow root, with adata-theme="light|dark"attribute for sites that want to force one.
if (theme === "dark") style.textContent += dark;
else if (theme !== "light") style.textContent += "@media (prefers-color-scheme: dark){" + dark + "}";
Wrapping up
If you build anything that runs on other people's sites, assume the page is hostile: isolate your styles, never trust text, restrict who can call you, and rate-limit everything that costs money.
I ended up packaging the whole thing — widget, Next.js 16 dashboard, Stripe billing, Apple/Google/passwordless login and 100+ tests — as a boilerplate called SupportKit AI. There's a 1-minute demo here if you're curious: https://supportkit-ai.netlify.app
Happy to answer questions about any of these in the comments.
Top comments (0)