A vite plugin for automatically bundling ChatGPT widget outputs within a vite project.
npm install vite-plugin-chatgpt-widgets
# or
pnpm add vite-plugin-chatgpt-widgets
Add the plugin to your vite.config.ts
and enable the build manifest:
import { defineConfig } from "vite";
import { chatGPTWidgetPlugin } from "vite-plugin-chatgpt-widgets";
export default defineConfig({
plugins: [
chatGPTWidgetPlugin({
widgetsDir: "web/chatgpt-widgets", // default: 'web/chatgpt-widgets'
baseUrl: "https://example.com", // if not using a vite `base`, this is required because the chatgpt iframe is sandboxed and absolute URL links are required
}),
],
build: {
manifest: true, // Required for production mode
},
server: {
cors: {
// allow cross origin requests for development assets so the ChatGPT sandbox can access dev-time assets
origin: true,
},
},
});
Create React components in your widgets directory:
// in web/chatgpt-widgets/Hello.tsx
export default function Hello() {
return <div>Hello from ChatGPT Widget!</div>;
}
// in web/chatgpt-widgets/ListFoobars.tsx
export default function ListFoobars() {
return (
<ul>
{window.openai.tool_output.foobars.map((foobar) => (
<li>{foobar}</li>
))}
</ul>
);
}
You can optionally create a root layout component that will wrap all widgets. If a file named root.tsx
(or root.ts
, root.jsx
, root.js
) exists in the widgets directory, it will automatically wrap all other widget components:
// web/chatgpt-widgets/root.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<SomeProvider>
<header>Common Header</header>
{children}
</SomeProvider>
);
}
The root layout component:
- Must accept a
children
prop - Will automatically wrap every widget component
- Is optional - if not present, widgets render without a wrapper
After setting up the plugin and writing some widgets, you need to expose the widget HTML snippets generated by this plugin as MCP server resources. In development, the HTML snippets will be generated by Vite dynamically, and in production, they'll be built by your Vite build process and read off the disk.
import { getWidgets } from "vite-plugin-chatgpt-widgets";
// In development, pass the Vite dev server instance from wherever you can get it
const widgets = await getWidgets("web/chatgpt-widgets", viteDevServer);
// In production, pass a path to the vite manifest, where we'll load precompiled versions from
const widgets = await getWidgets("web/chatgpt-widgets", {
manifestPath: "dist/.vite/manifest.json",
});
// Register each widget on an MCP server as a resource for exposure to ChatGPT
for (const widget of widgets) {
const resourceName = `widget-${widget.name.toLowerCase()}`;
const resourceUri = `ui://widget/${widget.name}.html`;
// assuming you are using @modelcontextprotocol/sdk, will be similar for other MCP implementations
mcpServer.registerResource(
resourceName,
resourceUri,
{
title: widget.name,
description: `ChatGPT widget for ${widget.name}`,
},
async () => {
return {
contents: [
{
uri: resourceUri,
mimeType: "text/html+skybridge",
text: widget.content,
},
],
};
}
);
}
When serving widgets in sandboxed iframes like ChatGPT's UI, asset links must be fully qualified URLs with protocol and domain. The user's browser loads up your widget on an OpenAI controlled domain, so asset loads must refer directly back to your hosting provider. The plugin enforces this requirement and will throw an error if an absolute base URL is not configured.
You must configure an absolute base URL in one of these ways:
- Vite's
base
config: If you've already configured Vite withbase: "https://example.com/"
, the plugin will use it automatically.
// Option 1: In Vite config (affects both dev and build)
export default defineConfig({
plugins: [chatGPTWidgetPlugin({})],
base: "https://example.com",
});
baseUrl
option to this plugin:: If Vite'sbase
is not set, or must be relative,, provide thebaseUrl
option to this plugin directly:
// Option 1: In Vite config (affects both dev and build)
export default defineConfig({
plugins: [
chatGPTWidgetPlugin({
baseUrl: "https://example.com",
}),
],
base: "/",
});
The plugin creates virtual modules for each widget component:
- Virtual HTML file:
virtual:chatgpt-widget-{name}.html
- A standalone HTML page - Virtual JS entrypoint:
virtual:chatgpt-widget-{name}.js
- Imports and renders your React component
During build, these are added as entrypoints and bundled into separate HTML files with hashed asset names. The getWidgetHTML
helper:
- In dev mode: Uses Vite's plugin container to load and transform the HTML in real-time
- In production: Reads the built HTML files using Vite's manifest.json to locate them
Plain React SPAs: Well supported
React Router v6 or React Router v7 in Declarative mode: Well supported
React Router v7 in Data or Framework Mode: Hackily supported
The Vite plugin.
Options:
widgetsDir
(string, optional): Directory containing widget components. Default:'web/chatgpt-widgets'
baseUrl
(string, optional): Base URL for widget assets. Required if Vite'sbase
config is not an absolute URL and you need fully qualified URLs for sandboxed iframes. Should include protocol and domain (e.g.,"https://example.com"
). Note: Does not require trailing slash.
Get the HTML content for a widget.
Parameters:
widgetsDir
(string): The path to the directory on disk with your widget componentsviteHandle
(DevelopmentViteBuild | ProductionViteBuild): A reference to a Vite context we can use for getting widget content.- In dev: Pass an object like
{ devServer: ViteDevServer }
to give the Vite dev server to use to build HTML - In prod: Pass an object like
{ mainfest: "some/path/to/.vite/manifest.json" }
to list all the entrypoints built by the vite build process
- In dev: Pass an object like