·Updated

Building Embeddable React Widgets: Production-Ready Guide

Learn to build production-ready embeddable React widgets. Bundle with Rollup, isolate styles with Shadow DOM, and deploy a single script that works on any website.

Embeddable widgets let your customers add your product to their site with a single script tag. Intercom, Drift, Hotjar, and dozens of analytics tools use this pattern.

This guide shows you how to build one: a React component bundled as a standalone JavaScript file, styled with Tailwind CSS, and isolated from host page styles using Shadow DOM. Tested with Vite 6, Rollup 4, React 19, and Node 22.

An embeddable React widget is a self-contained JavaScript bundle that renders a React component on any website via a single <script> tag. It uses Shadow DOM for style isolation and Rollup to produce an IIFE bundle that runs immediately when loaded.

What you'll build:

  • A Vite + React project with a separate widget entry point
  • A Rollup config that outputs a single IIFE bundle (~45KB gzipped for a basic widget)
  • Shadow DOM isolation so your styles never leak
  • Environment-based configuration for dev and production

The complete source code is on GitHub. Clone it and follow along.

When to Use This Approach

Use embeddable widgets when:

  • Your widget must work on any website regardless of their tech stack
  • You need complete style isolation from host pages
  • Your customers are non-technical and need a one-line install
  • You're building chat, feedback, analytics, or support tools

Consider alternatives when:

  • The widget only runs on domains you control (use a regular React component)
  • Bundle size is critical and you need under 20KB (consider vanilla JS)
  • You need deep integration with the host page's state (use npm package instead)

If unsure: Start with Shadow DOM isolation. You can always remove it later, but retrofitting isolation onto an existing widget is painful.

Project Setup

Create a new Vite project:

npm create vite@latest react-widget -- --template react-ts
cd react-widget

Install the dependencies (npm shown; substitute pnpm or yarn as preferred):

npm install react react-dom
npm install -D tailwindcss postcss tslib \
@rollup/plugin-babel @rollup/plugin-commonjs \
@rollup/plugin-node-resolve @rollup/plugin-typescript \
@rollup/plugin-terser @rollup/plugin-replace \
rollup rollup-plugin-postcss rollup-plugin-polyfill-node \
rollup-plugin-tsconfig-paths rollup-plugin-inject-process-env \
rollup-plugin-visualizer \
@babel/preset-react @babel/preset-typescript \
@types/react @types/react-dom typescript @types/node dotenv

Project Structure

Organize the project to separate widget code from the Vite dev app:

react-embeddable-widget/
├── src/
│ ├── widget/
│ │ ├── components/
│ │ ├── lib/
│ │ ├── styles/
│ │ └── index.tsx # Widget entry point
│ ├── App.tsx # Vite dev app (for testing)
│ └── main.tsx
├── rollup.config.mjs
├── tailwind.config.mjs
├── postcss.config.cjs
├── .env.development
└── .env.production

The src/widget/ directory contains everything that ships to customers. The root src/ files are your local dev environment for testing the widget with hot reload.

Rollup Configuration

Rollup bundles your React widget into a single IIFE (Immediately Invoked Function Expression) that runs when loaded:

rollup.config.mjs

import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import terser from '@rollup/plugin-terser';
import typescript from '@rollup/plugin-typescript';
import { config } from 'dotenv';
import { parseArgs } from 'node:util';
import injectProcessEnv from 'rollup-plugin-inject-process-env';
import nodePolyfills from 'rollup-plugin-polyfill-node';
import postcss from 'rollup-plugin-postcss';
import tsConfigPaths from 'rollup-plugin-tsconfig-paths';
import { visualizer } from 'rollup-plugin-visualizer';
const args = parseArgs({
options: {
environment: {
type: 'string',
short: 'e',
default: 'development',
},
},
});
const env = args.values.environment;
const production = env === 'production';
const envFile = production ? './.env.production' : './.env.development';
console.log(`Building widget for ${env}...`);
const ENV_VARIABLES = config({ path: envFile }).parsed;
const fileName = ENV_VARIABLES.WIDGET_NAME || 'widget.js';
export default {
input: './src/widget/index.tsx',
output: {
file: `dist/${fileName}`,
format: 'iife',
sourcemap: false,
inlineDynamicImports: true,
globals: {
'react/jsx-runtime': 'jsxRuntime',
'react-dom/client': 'ReactDOM',
react: 'React',
},
},
plugins: [
tsConfigPaths({ tsConfigPath: './tsconfig.json' }),
replace({ preventAssignment: true }),
typescript({ tsconfig: './tsconfig.json' }),
nodeResolve({
extensions: ['.tsx', '.ts', '.json', '.js', '.jsx', '.mjs'],
browser: true,
dedupe: ['react', 'react-dom'],
}),
babel({
babelHelpers: 'bundled',
presets: [
'@babel/preset-typescript',
['@babel/preset-react', {
runtime: 'automatic',
targets: '>0.1%, not dead, not op_mini all',
}],
],
extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs'],
}),
postcss({
extensions: ['.css'],
minimize: true,
extract: true,
inject: { insertAt: 'top' },
}),
commonjs(),
nodePolyfills({ exclude: ['crypto'] }),
injectProcessEnv(ENV_VARIABLES),
terser({
ecma: 2020,
mangle: { toplevel: true },
compress: {
module: true,
toplevel: true,
unsafe_arrows: true,
drop_console: true,
drop_debugger: true,
},
}),
visualizer(),
],
};

Yes, that's a lot of plugins. Widget bundling is fiddly.

Flowchart showing Rollup build process from React component to IIFE bundle

PostCSS and Tailwind Config

postcss.config.cjs

module.exports = {
plugins: {
tailwindcss: "./tailwind.config.mjs"
}
}

tailwind.config.mjs

export default {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: { extend: {} },
plugins: []
}

Building the Widget Components

State Management with Context

Create a context for widget state. This keeps things simple without external dependencies:

src/widget/lib/context.ts

import { createContext } from 'react';
interface WidgetContextType {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
clientKey: string;
}
export const WidgetContext = createContext<WidgetContextType>({
isOpen: false,
setIsOpen: () => undefined,
clientKey: '',
});

Widget Container

The container manages mounting state and provides context:

src/widget/components/widget-container.tsx

import { useState, useEffect } from 'react';
import { WidgetContext } from '../lib/context';
import { Widget } from './widget';
interface WidgetContainerProps {
clientKey: string;
}
export function WidgetContainer({ clientKey }: WidgetContainerProps) {
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<WidgetContext.Provider value={{ isOpen, setIsOpen, clientKey }}>
<Widget />
</WidgetContext.Provider>
);
}

The Widget component contains your actual UI (button, popup, chat interface, etc.). See the full implementation on GitHub.

Widget Styles

Use Tailwind's @apply directive in a dedicated CSS file:

src/widget/styles/style.css

.widget-container {
@apply fixed bottom-5 right-5 w-[300px] h-[400px]
bg-white border border-gray-200 rounded-lg
shadow-lg z-[9999];
}
.widget-button {
@apply fixed bottom-5 right-5 px-6 py-3
bg-indigo-600 text-white border-none
rounded-lg cursor-pointer z-[9999];
}
.widget-header {
@apply p-3 border-b border-gray-200
flex justify-between items-center;
}
.widget-content {
@apply p-4;
}

The Entry Point: Shadow DOM Isolation

The entry point initializes the widget, creates a Shadow DOM container, and injects styles. This isolation prevents your CSS from affecting the host page and vice versa:

src/widget/index.tsx

import { hydrateRoot } from 'react-dom/client';
import { WidgetContainer } from './components/widget-container';
import './styles/style.css';
function initializeWidget() {
if (document.readyState !== 'loading') {
onReady();
} else {
document.addEventListener('DOMContentLoaded', onReady);
}
}
function onReady() {
try {
const element = document.createElement('div');
const shadow = element.attachShadow({ mode: 'open' });
const shadowRoot = document.createElement('div');
const clientKey = getClientKey();
shadowRoot.id = 'widget-root';
const component = <WidgetContainer clientKey={clientKey} />;
shadow.appendChild(shadowRoot);
injectStyle(shadowRoot);
hydrateRoot(shadowRoot, component);
document.body.appendChild(element);
} catch (error) {
console.warn('Widget initialization failed:', error);
}
}
function injectStyle(shadowRoot: HTMLElement) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = process.env.WIDGET_CSS_URL || '/style.css';
shadowRoot.appendChild(link);
}
function getClientKey() {
const script = document.currentScript as HTMLScriptElement;
const clientKey = script?.getAttribute('data-client-key');
if (!clientKey) {
throw new Error('Missing data-client-key attribute');
}
return clientKey;
}
initializeWidget();
Flowchart showing widget initialization sequence from script load to React render

Reading Script Tag Attributes

The getClientKey() function extracts configuration from the script tag that loaded the widget:

<script
src="https://cdn.example.com/widget.js"
data-client-key="client_abc123">
</script>

Use document.currentScript to access the loading script, then read data-* attributes via getAttribute() or the dataset property:

const clientKey = document.currentScript.dataset.clientKey;

This pattern lets you pass customer IDs, feature flags, or any configuration without requiring API calls at load time.

Build Scripts

Add these scripts to package.json:

{
"scripts": {
"dev": "vite",
"build:widget": "rollup -c ./rollup.config.mjs",
"build:widget:production": "rollup -c ./rollup.config.mjs --environment=production",
"serve": "npx http-server ./ --cors -p 3333",
"serve:widget": "npx http-server ./dist --cors -p 3334"
}
}

Environment Configuration

Development (.env.development)

WIDGET_NAME=widget.js
WIDGET_CSS_URL=http://localhost:3334/style.css

Production (.env.production)

WIDGET_NAME=widget.js
WIDGET_CSS_URL=https://your-cdn.com/widget.css

Testing the Widget

Option 1: Use Vite for Development

Import the widget container directly into your Vite app for hot reload during development:

src/App.tsx

import './App.css';
import './widget/styles/style.css';
import { WidgetContainer } from './widget/components/widget-container';
function App() {
return <WidgetContainer clientKey="test-key" />;
}
export default App;

Run npm run dev and you'll see the widget with instant feedback on changes.

Screenshot of widget button rendered in browser during development

Option 2: Test the Bundled Widget

Create a test HTML file to verify the production bundle works:

test/index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Widget Test Page</title>
</head>
<body>
<h1>Test Page</h1>
<p>This page tests the widget integration.</p>
<script async src="../dist/widget.js" data-client-key="test-key"></script>
</body>
</html>

Build and serve:

npm run build:widget
npm run serve &
npm run serve:widget &

Open http://localhost:3333/test/index.html to see the bundled widget running.

Production Deployment

Build for production:

npm run build:widget:production

The dist/ folder contains:

  • widget.js - The production bundle
  • style.css - The production styles

Upload these to your CDN. Your customers embed the widget with:

<script
src="https://your-cdn.com/widget.js"
data-client-key="CUSTOMER_KEY">
</script>

Common Pitfalls

These cost us a full afternoon of debugging when building widgets for MakerKit. Learn from our pain:

Using createRoot instead of hydrateRoot: Causes a double-render flicker on some browsers. The entry point above uses hydrateRoot for smoother initialization.

Forgetting mode: 'open' on Shadow DOM: Using mode: 'closed' prevents your customers from debugging issues on their site. Keep it open unless you have specific security requirements.

Not handling document.currentScript being null: If your script loads with async or defer, document.currentScript may be null by the time your code runs. The entry point above handles this gracefully.

CSS-in-JS performance in Shadow DOM: We initially used styled-components but found that injecting styles into Shadow DOM added 150-200ms to initialization. Extracted CSS with Tailwind loads via a standard <link> tag and performs better.

Assuming z-index: 9999 is enough: Some sites use higher values. Test on real customer sites early, and consider making z-index configurable via data attributes.

Production Considerations

Performance

  • A basic widget with Tailwind should be ~45KB gzipped
  • Lazy load non-critical components (modals, settings panels)
  • Use terser for minification (already configured above)

Browser Compatibility

The config targets >0.1%, not dead, not op_mini all. Test on Chrome, Firefox, Safari, and Edge. Shadow DOM has 96% browser support globally, so you're covered unless you need IE11 (in which case, good luck).

Security

  • Validate the data-client-key on your backend
  • Set appropriate CORS headers on your API endpoints (see our guide on securing Next.js Server Actions for patterns)
  • Consider CSP implications for sites embedding your widget

Error Handling

Wrap initialization in try/catch (done above). Use a lightweight error reporting service like Sentry's browser SDK. The cardinal rule: fail silently rather than breaking the host page. Your widget is a guest on someone else's site.

Next Steps

This guide covers the foundation. For a complete implementation including real-time chat functionality, API integration with Next.js, message persistence with Supabase, and OpenAI-powered ticket titles, check out the MakerKit course module on building JavaScript widgets.

The complete source code for this tutorial is available on GitHub.

Frequently Asked Questions

Why use Shadow DOM instead of CSS namespacing?
True isolation. CSS namespacing (BEM, CSS Modules) can still be overridden by host page styles with higher specificity. Shadow DOM creates a hard boundary.
Can I use other bundlers like esbuild or webpack?
Yes. The concepts transfer directly. Rollup is popular for libraries and widgets because it produces clean IIFE bundles with excellent tree-shaking. esbuild is faster but has fewer plugins. Webpack works but typically produces larger bundles for this use case.
How do I handle multiple widgets on the same page?
Each script tag creates its own Shadow DOM container, so multiple instances work automatically. If you need shared state between instances, use a global event bus or localStorage.
What about React 19 and the new compiler?
This setup works with React 19. The React Compiler is optional and doesn't affect the bundling process. If you enable it, add the Babel plugin to your Rollup config.
How do I debug the production bundle?
Set sourcemap: true in the Rollup output config during debugging. For production, keep sourcemaps disabled or upload them privately to your error tracking service.
Can I use CSS-in-JS libraries like styled-components?
Yes, but they require extra configuration to inject styles into the Shadow DOM. Tailwind with extracted CSS is simpler for widgets since the styles load via a standard link tag.
How big should my widget bundle be?
Aim for under 100KB gzipped. A basic React + Tailwind widget should be around 45KB. If you're above 150KB, audit your dependencies and consider code splitting.