<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SupportKit AI</title>
    <description>The latest articles on DEV Community by SupportKit AI (@supportkitai).</description>
    <link>https://dev.to/supportkitai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4211774%2F32e2ac02-f5a8-4483-b4d2-03e567038605.png</url>
      <title>DEV Community: SupportKit AI</title>
      <link>https://dev.to/supportkitai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/supportkitai"/>
    <language>en</language>
    <item>
      <title>5 things I learned building an embeddable AI chat widget that runs on other people's websites</title>
      <dc:creator>SupportKit AI</dc:creator>
      <pubDate>Tue, 27 Oct 2026 22:14:47 +0000</pubDate>
      <link>https://dev.to/supportkitai/5-things-i-learned-building-an-embeddable-ai-chat-widget-that-runs-on-other-peoples-websites-55k0</link>
      <guid>https://dev.to/supportkitai/5-things-i-learned-building-an-embeddable-ai-chat-widget-that-runs-on-other-peoples-websites-55k0</guid>
      <description>&lt;p&gt;I recently built an AI support chatbot for websites: a site owner uploads their FAQ, gets an assistant, and drops one &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag on their site. A chat bubble appears in the corner and answers visitors' questions.&lt;/p&gt;

&lt;p&gt;The AI part was the easy bit. The hard part was that &lt;strong&gt;my code now runs inside websites I don't control&lt;/strong&gt;. Here are the five things that took the most time to get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Use Shadow DOM, or the host site's CSS will break you
&lt;/h2&gt;

&lt;p&gt;The first version used a normal &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;. On the very first test site, the host's global styles (&lt;code&gt;button { ... }&lt;/code&gt;, &lt;code&gt;* { ... }&lt;/code&gt;) turned my widget into a mess.&lt;/p&gt;

&lt;p&gt;The fix is to render everything inside a Shadow DOM and reset styles at the root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;my-widget&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachShadow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;style&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:host{all:initial} *{box-sizing:border-box;font-family:system-ui,sans-serif}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:host { all: initial }&lt;/code&gt; stops inherited styles from leaking in, and nothing you put inside the shadow root leaks out to the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Never render the AI's answer as HTML
&lt;/h2&gt;

&lt;p&gt;It's tempting to use &lt;code&gt;innerHTML&lt;/code&gt; 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 &lt;code&gt;&amp;lt;img src=x onerror=...&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every message is created with &lt;code&gt;textContent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;el&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// never innerHTML&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You lose rich formatting, but you can't get XSS through the chat. For a support widget that's a good trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Lock the widget to the sites the owner allows
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;corsHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allowedOrigins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowedOrigins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no CORS → browser blocks it&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET, POST, OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Vary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that bit me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Normalise origins when the owner saves them.&lt;/strong&gt; People paste &lt;code&gt;https://shop.com/some/page/&lt;/code&gt; — store &lt;code&gt;new URL(value).origin&lt;/code&gt;, and only allow &lt;code&gt;https:&lt;/code&gt; (plain &lt;code&gt;http:&lt;/code&gt; just for localhost).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same-origin requests have no &lt;code&gt;Origin&lt;/code&gt; header.&lt;/strong&gt; When the widget runs on the app's &lt;em&gt;own&lt;/em&gt; marketing page, a GET arrives without &lt;code&gt;Origin&lt;/code&gt; and gets rejected. &lt;code&gt;Sec-Fetch-Site: same-origin&lt;/code&gt; is set by the browser and can't be faked by a page, so it's safe to use for that case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CORS only protects against &lt;em&gt;browsers&lt;/em&gt;, so the server still checks the allow-list and rate-limits, which brings me to:&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rate-limit the public endpoint per IP
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Also: send no cookies. The widget uses &lt;code&gt;fetch(..., { credentials: "omit" })&lt;/code&gt;, because an anonymous widget has no reason to carry the visitor's cookies for your domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make it feel native: language and dark mode
&lt;/h2&gt;

&lt;p&gt;Small things made the biggest difference in how "finished" it felt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Language:&lt;/strong&gt; the widget sends the visitor's &lt;code&gt;Accept-Language&lt;/code&gt;, and the server returns the UI strings (placeholder, "Send", status line) in that language.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dark mode:&lt;/strong&gt; a few extra CSS rules inside &lt;code&gt;@media (prefers-color-scheme: dark)&lt;/code&gt; in the shadow root, with a &lt;code&gt;data-theme="light|dark"&lt;/code&gt; attribute for sites that want to force one.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@media (prefers-color-scheme: dark){&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dark&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;SupportKit AI&lt;/strong&gt;. There's a 1-minute demo here if you're curious: &lt;a href="https://supportkit-ai.netlify.app" rel="noopener noreferrer"&gt;https://supportkit-ai.netlify.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of these in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>javascript</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
