Building Embeddable React Widgets: A Complete Guide

Learn how to create embeddable React widgets for your web application. Inject your React widget into any website with ease.

Have you ever wondered how companies like Intercom or Drift create those chat widgets that can be embedded on any website with a single line of code?

In this guide, we'll create a production-ready, embeddable React widget from scratch using Vite.

Too bored to read this? No worries, I also provide you with a Github Repository with the full source code, so you can get started right away to embedding your Javascript Widget on your customers' websites using modern tooling such as React and TailwindCSS.

Project Setup

Let's start by creating a new project using Vite:

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

Installing Dependencies

We'll need several dependencies for our widget:

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

PS: if you use PNPM is better to use the following command:

pnpm install <package-name>

Project Structure

Let's organize our project with the following structure:

react-embeddable-widget/
├── src/
│ ├── widget/
| | ├── components/
| | ├── lib/
| | ├── styles/
| | ├── index.tsx
| └── App.tsx
| └── App.css
| └── main.tsx
| └── index.css
├── public/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── .env.development
└── .env.production
└── rollup.config.mjs
└── tailwind.config.mjs
└── postcss.config.cjs
  1. Widget Code: In src/widget we have the widget's code.
  2. Vite App code: In src we have the Vite app code. Useful for testing the widget, but not code that will be bundled with the widget.
  3. Public folder: This is where you can store your static assets, such as images, fonts, and so on.
  4. Configuration Files: We have configuration files for Vite, Rollup, Tailwind CSS, and PostCSS.
  5. Environment Variables: We have environment variables for development and production.

Configuration Files

Create a Rollup configuration file (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',
},
configuration: {
type: 'string',
short: 'c',
},
},
});
const env = args.values.environment;
const production = env === 'production';
let environmentVariablesPath = './.env.development';
console.log(`Building widget for ${env} environment...`);
if (production) {
environmentVariablesPath = './.env.production';
}
const ENV_VARIABLES = config({
path: environmentVariablesPath,
}).parsed;
const fileName = ENV_VARIABLES.WIDGET_NAME || 'widget.js';
export default {
input: './src/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,
},
output: { quote_style: 1 },
}),
visualizer(),
],
};

Adding PostCSS Plugin for bundling Tailwind CSS styles

Let's also add the PostCSS plugin to our configuration:

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

and the Tailwind CSS plugin to our configuration:

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

Building the Widget Components

1. Create a Context for State Management

First, let's create our context for managing widget state (src/lib/context.ts):

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

The WidgetContext is used to manage the state of the widget, such as whether it is open or closed or any other information you'd want to use.

You can use any state management library you prefer, but for this tutorial, we'll use React's built-in createContext function to create a context object that can be used to share state between components.

The WidgetContext is initialized with a default value of { isOpen: false, setIsOpen: () => undefined }. This means that the isOpen property is initially set to false, and the setIsOpen property is a function that does nothing when called.

2. Create the Widget Component

Create the main widget component (src/components/widget.tsx):

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

3. Create the Widget Container

Create the container component (src/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>
);
}

4. Add Styles with Tailwind CSS

It's time to add some base styles to our widget. We'll use [Tailwind CSS] (https://tailwindcss.com/) - as it's the most popular CSS framework for building UIs.

While you can add the styles directly to the widget component, I want to show you how to separate the styles from the components, so you can do that later on should you need to.

Create the CSS file (src/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;
}

5. Create the Entry Point

Next, let's create the main entry point file that initializes our widget. This file serves two purposes:

  1. It bootstraps the React application when the widget loads
  2. It serves as the primary bundle entry point for our build process

In the below example, we will:

  1. Load the widget container component
  2. Inject the widget styles that will be bundled with the widget
  3. Initialize the widget using hydrateRoot from react-dom/client. This function takes a React component and a DOM element, and renders the component into the DOM element.
  4. Call the initializeWidget function to initialize the widget. This function will either be called immediately if the DOM is already loaded, or it will be called when the DOM is ready.
  5. We use the Shadow DOM API to create a shadow root for the widget. This allows us to isolate the widget's styles and prevent them from affecting the rest of the page.
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();

Getting the Client Key

The getClientKey() function plays a crucial role in identifying and authenticating the widget for each customer. Here's how it works:

// Example of how the widget script is embedded
<script
src="https://cdn.example.com/widget.js"
data-client-key="client_abc123">
</script>

The function retrieves the data-client-key attribute from the script tag that loaded the widget. This key is typically unique to each customer and can be used to:

  • Load customer-specific configurations
  • Track widget usage per customer
  • Authenticate API requests from the widget
  • Apply customer-specific styling or features

If no client key is provided, the function throws an error to ensure proper initialization.

Retrieving attributes specified in the script tag

To retrieve the script tag that loaded the widget, you can use the document.currentScript property. This property returns the script tag that is currently executing, which is useful when you need to access the script tag that loaded the widget.

From there, you can access the dataset property to retrieve the attributes specified in the script tag.

Here's an example of how to retrieve the data-client-key attribute:

const clientKey = document.currentScript.dataset.clientKey;

You will probably require different attributes to specify configuration, or identify the consumer of the widget.

6. Update Package Scripts

Update your package.json scripts:

{
"scripts": {
// the other scripts...
"build:widget": "rollup -c ./rollup.config.mjs",
"build:widget:production": "rollup -c ./rollup.config.mjs --environment=production"
}
}

Managing Environments

As you can see, we have two build commands: build:widget and build:widget:production.

  1. Development: The build:widget command is used to build the widget for development
  2. Production: Instead, the build:widget:production command is used to build the widget for production.

Development Environment

When you run the build:widget command, the widget will be built for development: it will use the variables specified in the .env.development file.

Using the Vite App as a Development Environment

If you want to use Vite as your development environment, you can use the vite command instead of build:widget or build:widget:production.

This allows you to use Vite's features like hot module replacement, which can be useful for development.

Let's import the WidgetContainer component from the widget and use it in our Vite app:

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

Now, run the following command to start the development server:

npm run dev

You can now open your browser and navigate to the URL outputted by the command. You should see the widget container rendered on the page.

You should see the following page:

Production Environment

When you run the build:widget:production command, the widget will be built for production: it will use the variables specified in the .env.production file.

Testing the bundled Widget

To test the bundled widget, you can use a simple HTML file and load it in your browser using a script tag.

This is a better way to test the widget because it allows you to test the bundled widget processed by the Rollup bundler.

Creating the Test Page HTML File

Create a test HTML file to verify the widget 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>

Adding the development environment variables

To test the widget, you need to add the WIDGET_CSS_URL environment variable to the .env.development file.

This variable specifies the URL of the widget's CSS file. You can use the WIDGET_CSS_URL environment variable to specify the URL of the widget's CSS file.

For example, if you want to use the widget's CSS file at http://localhost:3334/style.css, you can set the WIDGET_CSS_URL environment variable to http://localhost:3334/style.css.

You can also use the WIDGET_CSS_URL environment variable to specify the URL of the widget's CSS file.

WIDGET_CSS_URL=http://localhost:3334/style.css

When you go to production, you can use the same approach to specify the URL to the CDN you use to serve the widget's CSS file.

Adding the scripts to the package.json to serve the widget

Add the following command to your package.json scripts:

{
"scripts": {
"serve": "npx http-server ./ --cors -p 3333",
"serve:widget": "npx http-server ./dist --cors -p 3334"
}
}

Building the widget

Before running the test page, you need to build the widget. You can use the following commands:

npm run build:widget

Running the Test Page

Use the commands below to start the server:

npm run serve &
npm run serve:widget &

Now, open your browser and navigate to http://localhost:3333/test.html.

The commands above will start two servers: one for the widget and one for the test page.

Building and Deploying

  1. Build the widget:
npm run build:widget
# or
npm run build:widget:production
  1. Test locally using the Vite App:
npm run serve
  1. For production deployment, update your environment variables:
# .env.production
WIDGET_CSS_URL=https://your-cdn.com/widget.css

Then build for production:

npm run build:production

The dist folder will contain the production bundle of the widget in the following files:

  • widget.js: The production bundle of the widget
  • widget.css: The production bundle of the widget's styles

The name is customizable using the WIDGET_NAME environment variable.

Usage in Production

Once deployed, the widget can be embedded on any website with a single script tag:

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

Best Practices and Considerations

  1. Performance
    • Lazy load non-critical components
    • Implement code splitting
    • Optimize bundle size
  2. Compatibility
    • Test across different browsers
    • Consider polyfills for older browsers
    • Handle graceful degradation
  3. Security
    • Implement proper CORS headers
    • Sanitize user inputs
    • Use Content Security Policy (CSP)
  4. Error Handling
    • Implement error boundaries
    • Add logging and monitoring
    • Handle initialization failures gracefully

Conclusion

You now have a foundation for building an embeddable React widget!

This approach using Vite and Rollup provides a modern development experience while ensuring your widget can be easily distributed and embedded on any website.

In addition to React, this guide also covers how to use Tailwind CSS and PostCSS to build a widget with a clean and modern design and leveraging an incredible developer experience.

Your next steps

Remember to:

  • Keep your bundle size small (avoid large dependencies)
  • Test thoroughly in different environments
  • Implement proper error handling
  • Consider security implications
  • Document usage and configuration options

The complete source code for this project is available on GitHub. Feel free to fork and contribute!

💡Makerkit - A production-ready Next.js SaaS Boilerplate

Need a battle-tested Next.js SaaS starter kit? MakerKit provides a comprehensive boilerplate that extends beyond basic Next.js and Supabase integration, offering everything you need to launch your SaaS product faster.