Every SaaS product eventually needs an embeddable widget. A chat bubble. A feedback form. A reviews display. A booking calendar.
Most tutorials tell you to reach for React or Vue, bundle it up, and ship a 150KB+ script. Your users paste it into their site, and their PageSpeed score drops by 15 points.
There's a better way. I'll walk you through the architecture I used to build an embeddable review widget that loads in under 30KB, renders in a Shadow DOM, and works on any website without conflicting with the host page's CSS or JavaScript.
The Architecture
An embeddable widget has three jobs:
-
Load — a single
<script>tag the user pastes into their HTML - Isolate — render inside a Shadow DOM so your styles don't leak out and host styles don't leak in
- Fetch & Render — pull data from your API and display it
That's it. No build step for the host. No npm install. No framework dependency. One script tag, and it works.
<script src="https://cdn.example.com/widget.js" data-widget-id="abc123" async></script>
Step 1: The Loader Script
The loader is the entry point. It needs to:
- Find all elements on the page that reference your widget
- Create a container for each one
- Fetch widget configuration from your API
- Render the widget
(function() {
'use strict';
const SCRIPT_TAG = document.currentScript;
const API_BASE = 'https://api.example.com';
// Grab the widget ID from the script tag's data attribute
const widgetId = SCRIPT_TAG?.getAttribute('data-widget-id');
if (!widgetId) return;
// Create a container right after the script tag
const container = document.createElement('div');
SCRIPT_TAG.parentNode.insertBefore(container, SCRIPT_TAG.nextSibling);
// Fetch config and render when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => init(container, widgetId));
} else {
init(container, widgetId);
}
})();
Two things matter here:
document.currentScript gives you a reference to the <script> tag itself, which is how you read data-widget-id without requiring the user to add a separate <div>. Some widgets require both a script and a div — that's one more thing users can get wrong during installation. A single script tag is simpler.
async on the script tag means your widget loads without blocking the host page. Their Largest Contentful Paint score stays clean.
Step 2: Shadow DOM for Style Isolation
This is the part most widget tutorials skip, and it's the part that causes 90% of support tickets: "your widget broke my site's layout" or "my site's CSS is overriding your widget styles."
Shadow DOM solves both problems.
function createShadowContainer(hostElement) {
const shadow = hostElement.attachShadow({ mode: 'closed' });
// Inject scoped styles directly — no external CSS file needed
const style = document.createElement('style');
style.textContent = getWidgetStyles();
shadow.appendChild(style);
const wrapper = document.createElement('div');
wrapper.className = 'widget-root';
shadow.appendChild(wrapper);
return wrapper;
}
Why mode: 'closed'? Because you don't want the host page's JavaScript reaching into your widget's DOM and accidentally breaking it. Closed mode means hostElement.shadowRoot returns null from outside. Your widget's internals stay private.
The CSS you inject into the Shadow DOM is completely scoped. The host page's * { box-sizing: border-box; } or .card { padding: 0; } won't touch your widget. And your widget's .card class won't affect theirs.
Performance note: Inline styles inside Shadow DOM are tiny compared to loading an external stylesheet (which is a separate HTTP request). For a widget under 30KB total, inlining CSS is the right call.
Step 3: Data Fetching with Caching
Your widget needs data from your API. The naive approach hits your server on every page load. A smarter approach caches in localStorage with a TTL.
async function fetchWidgetData(widgetId) {
const cacheKey = `widget_${widgetId}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { data, expires } = JSON.parse(cached);
if (Date.now() < expires) return data;
}
const res = await fetch(`${API_BASE}/widgets/${widgetId}`);
if (!res.ok) throw new Error(`Widget fetch failed: ${res.status}`);
const data = await res.json();
// Cache for 1 hour
localStorage.setItem(cacheKey, JSON.stringify({
data,
expires: Date.now() + 3600000
}));
return data;
}
This has a big impact on perceived performance. The first load hits your API. Every subsequent page load (within the TTL) renders instantly from cache. For a multi-page site, this means your widget appears in under 50ms on the second page the user visits.
Edge case: Some sites disable localStorage (privacy modes, cookie consent tools). Wrap it in a try/catch and fall back to fetching every time.
Step 4: Rendering Without a Framework
You don't need React to render a list of cards. Template literals and innerHTML work fine for a widget.
function renderReviews(container, reviews, config) {
const { theme, maxItems, layout } = config;
const html = `
<div class="reviews-widget reviews-${layout}" data-theme="${theme}">
<div class="reviews-header">
<div class="stars">${renderStars(config.avgRating)}</div>
<span class="review-count">${config.totalReviews} reviews</span>
</div>
<div class="reviews-list">
${reviews.slice(0, maxItems).map(review => `
<div class="review-card">
<div class="review-meta">
<span class="reviewer-name">${escapeHtml(review.author)}</span>
<span class="review-date">${formatDate(review.date)}</span>
</div>
<div class="review-stars">${renderStars(review.rating)}</div>
<p class="review-text">${escapeHtml(review.text)}</p>
</div>
`).join('')}
</div>
</div>
`;
container.innerHTML = html;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
Critical: always escape user content. Reviews contain user-submitted text. Without escapeHtml(), you're opening your widget (and every site that embeds it) to XSS attacks. This is non-negotiable.
The renderStars() function can use inline SVG for star icons instead of loading an icon font (saving another 20-50KB).
Step 5: Responsive Layout Without Media Queries
Your widget will be placed inside containers of unpredictable widths. Media queries based on viewport width won't help. You need container-aware sizing.
function setupResponsive(container) {
if ('ResizeObserver' in window) {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const width = entry.contentRect.width;
const el = entry.target;
el.classList.toggle('compact', width < 400);
el.classList.toggle('medium', width >= 400 && width < 700);
el.classList.toggle('wide', width >= 700);
}
});
observer.observe(container);
}
}
Now your CSS inside Shadow DOM can use .compact .review-card, .medium .review-card, and .wide .review-card to adapt the layout based on the actual container width, not the viewport. This works correctly whether your widget is in a narrow sidebar or a full-width section.
ResizeObserver has 97% browser support. For the remaining 3%, the widget just renders in the default layout.
Step 6: JSON-LD Schema for SEO
If your widget displays reviews, adding structured data helps the host site earn rich snippets (star ratings in Google search results). This is a genuine value-add for your users.
function injectSchema(config) {
const schema = {
'@context': 'https://schema.org',
'@type': 'AggregateRating',
'itemReviewed': {
'@type': 'LocalBusiness',
'name': config.businessName
},
'ratingValue': config.avgRating,
'reviewCount': config.totalReviews,
'bestRating': 5,
'worstRating': 1
};
const script = document.createElement('script');
script.type = 'application/ld+json';
script.textContent = JSON.stringify(schema);
document.head.appendChild(script);
}
Note that this injects into the main document <head>, not the Shadow DOM. Search engine crawlers read the main document; they don't parse Shadow DOM content. Injecting schema into the light DOM ensures Google picks it up.
Putting It All Together
async function init(container, widgetId) {
try {
const root = createShadowContainer(container);
const data = await fetchWidgetData(widgetId);
renderReviews(root, data.reviews, data.config);
setupResponsive(root);
if (data.config.schema) {
injectSchema(data.config);
}
} catch (err) {
// Silent fail — don't break the host page
console.warn('[Widget]', err.message);
}
}
The full script, minified and gzipped, comes in under 30KB. For comparison, Elfsight's widget script is 80-120KB, and apps built with React add 40KB+ just for the framework.
What This Architecture Gives You
| Concern | Solution |
|---|---|
| Style isolation | Shadow DOM (closed mode) |
| Performance | async loading, localStorage cache, no framework |
| Responsiveness | ResizeObserver, not media queries |
| SEO | JSON-LD injected into light DOM |
| Security | HTML escaping, closed Shadow DOM |
| Bundle size | Under 30KB gzipped |
| Installation | One <script> tag |
Lessons From Production
I used this exact architecture to build EveryWidget, a review widget that pulls from 33+ review platforms (Google, Yelp, Trustpilot, etc.) and renders on any website. A few things I learned shipping it:
Test on Wix and Squarespace early. These platforms sandbox third-party scripts inside iframes, which means your
document.currentScripttrick might not work. You may need a fallback that finds your script bysrcattribute.Cache aggressively on the CDN, bust cache per version. Serve
widget.js?v=12and set a longCache-Controlheader. When you ship updates, bump the version. Don't make users clear their browser cache.Always fail silently. If your API is down or the widget ID is wrong, log a
console.warnand move on. Never throw an error that disrupts the host page. Your users' customers will blame their site, not your widget.Offer a
<noscript>fallback. For SEO-critical content (like reviews), include a<noscript>tag with a plain-text summary and a link to the review source. This gives search engines something to index even if they don't execute JavaScript.
If you're building an embeddable widget and want to see this architecture in action, check out EveryWidget. It's free to try and the embed code is literally one line.
Got questions about any of these patterns? Drop a comment — happy to go deeper on Shadow DOM, caching strategies, or the Wix/Squarespace edge cases.