How to Build a Lightweight Embeddable Widget in Vanilla JS (Under 30KB)

javascript dev.to

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:

  1. Load — a single <script> tag the user pastes into their HTML
  2. Isolate — render inside a Shadow DOM so your styles don't leak out and host styles don't leak in
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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);
  }
})();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Test on Wix and Squarespace early. These platforms sandbox third-party scripts inside iframes, which means your document.currentScript trick might not work. You may need a fallback that finds your script by src attribute.

  2. Cache aggressively on the CDN, bust cache per version. Serve widget.js?v=12 and set a long Cache-Control header. When you ship updates, bump the version. Don't make users clear their browser cache.

  3. Always fail silently. If your API is down or the widget ID is wrong, log a console.warn and move on. Never throw an error that disrupts the host page. Your users' customers will blame their site, not your widget.

  4. 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.

Source: dev.to

arrow_back Back to Tutorials