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

*Published: 2024-12-23*
*Canonical: https://makerkit.dev/blog/tutorials/embeddable-widgets-react*

---

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.

{% callout title="What is an embeddable widget?" %}
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.
{% /callout %}

**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](https://github.com/makerkit/react-embeddable-widget). 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:

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

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

```bash
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:

```javascript {% title="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.

{% img src="/images/posts/embeddable-widget-build-flowchart.webp" width="573" height="816" alt="Flowchart showing Rollup build process from React component to IIFE bundle" /%}

### PostCSS and Tailwind Config

```js {% title="postcss.config.cjs" %}
module.exports = {
  plugins: {
    tailwindcss: "./tailwind.config.mjs"
  }
}
```

```js {% title="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:

```typescript {% title="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:

```typescript {% title="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](https://github.com/makerkit/react-embeddable-widget/blob/main/src/widget/components/widget.tsx).

### Widget Styles

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

```css {% title="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:

```tsx {% title="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();
```

{% img src="/images/posts/embeddable-widget-initialization-flowchart.webp" width="482" height="1718" alt="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:

```html
<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:

```javascript
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`:

```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)

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

### Production (.env.production)

```bash
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:

```tsx {% title="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.

{% img src="/images/posts/embeddable-widget-button.webp" width="1280" height="720" alt="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:

```html {% title="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:

```bash
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:

```bash
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:

```html
<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](https://caniuse.com/shadowdomv1) 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](/blog/tutorials/secure-nextjs-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](/courses/nextjs-turbo/building-javascript-widget).

The complete source code for this tutorial is available on [GitHub](https://github.com/makerkit/react-embeddable-widget).

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "Why use Shadow DOM instead of CSS namespacing?", "answer": "True isolation. CSS namespacing (BEM, CSS Modules) can still be overridden by host page styles with higher specificity. Shadow DOM creates a hard boundary."},
     {"question": "Can I use other bundlers like esbuild or webpack?", "answer": "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."},
     {"question": "How do I handle multiple widgets on the same page?", "answer": "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."},
     {"question": "What about React 19 and the new compiler?", "answer": "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."},
     {"question": "How do I debug the production bundle?", "answer": "Set sourcemap: true in the Rollup output config during debugging. For production, keep sourcemaps disabled or upload them privately to your error tracking service."},
     {"question": "Can I use CSS-in-JS libraries like styled-components?", "answer": "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."},
     {"question": "How big should my widget bundle be?", "answer": "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."}
   ]
/%}
