This guide explains how to build a GoodWidget mini app and publish it as an npm package that consumers can use in React (web), React Native, and plain HTML (Web Component).
- Project Structure
- Step 1 — Build your widget as a React component
- Step 2 — Add a Web Component entry point
- Step 3 — Configure the build
- Step 4 — Configure package.json exports
- Step 5 — Build & publish
- Consumer guides
- Theme overridability checklist
- Reference: full example package
A publishable GoodWidget package has three entry points:
my-widget/
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── src/
├── index.ts # React component + types (main entry)
├── element.ts # Web Component class (optional entry)
└── register.ts # Auto-registration side effect (optional entry)
└── MyWidget.tsx # The actual widget implementation
| Entry | Purpose | Who imports it |
|---|---|---|
index.ts |
Exports the React component, types, and the Web Component class | React & React Native consumers |
element.ts |
Exports only the Custom Element class | Consumers who want to register it themselves |
register.ts |
Side-effect import that calls customElements.define() |
HTML consumers (<script> tag) |
Use @goodwidget/core for wallet/host context and @goodwidget/ui for
components. Wrap everything in GoodWidgetProvider so the widget is
self-contained.
// src/MyWidget.tsx
import React from 'react'
import { GoodWidgetProvider, useWallet } from '@goodwidget/core'
import type { EIP1193Provider, GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/core'
import {
Card, Heading, Text, Button, ButtonText, YStack,
} from '@goodwidget/ui'
function Inner() {
const { address, connect } = useWallet()
return (
<YStack gap="$3" padding="$2">
<Card>
<Heading level={5}>My Widget</Heading>
<Text secondary>{address ?? 'Not connected'}</Text>
<Button fullWidth onPress={address ? undefined : connect}>
<ButtonText>{address ? 'Do Something' : 'Connect'}</ButtonText>
</Button>
</Card>
</YStack>
)
}
export interface MyWidgetProps {
provider?: EIP1193Provider
config?: GoodWidgetConfig
themeOverrides?: GoodWidgetThemeOverrides
defaultTheme?: 'light' | 'dark'
}
export function MyWidget({ provider, config, themeOverrides, defaultTheme = 'light' }: MyWidgetProps) {
return (
<GoodWidgetProvider
provider={provider}
config={config}
themeOverrides={themeOverrides}
defaultTheme={defaultTheme}
>
<Inner />
</GoodWidgetProvider>
)
}- Self-contained provider: Each widget wraps itself in
GoodWidgetProvider. This lets hosts passthemeOverridesandproviderfrom outside. - Use
createComponent()for any custom styled components so they get anameand are registered in the theme manifest for host overridability. - Don't import
@goodwidget/embedhere — that's only for the Web Component entry point.
// src/element.ts
import { createMiniAppElement } from '@goodwidget/embed'
import { MyWidget } from './MyWidget'
export const MyWidgetElement = createMiniAppElement(
MyWidget as React.ComponentType<Record<string, unknown>>,
{
shadow: true, // Shadow DOM for style isolation
defaultTheme: 'light',
events: ['action'], // Custom events the widget emits
},
)// src/register.ts
import { MyWidgetElement } from './element'
const TAG = 'my-widget'
export function register(tagName: string = TAG): string {
if (!customElements.get(tagName)) {
customElements.define(tagName, MyWidgetElement)
}
return tagName
}
// Auto-register on import
register()// src/index.ts
export { MyWidget } from './MyWidget'
export type { MyWidgetProps } from './MyWidget'
export { MyWidgetElement } from './element'
export { register } from './register'Use tsup to produce ESM + CJS bundles with TypeScript declarations.
// tsup.config.ts
import { defineConfig } from 'tsup'
export default defineConfig({
entry: {
index: 'src/index.ts',
element: 'src/element.ts',
register: 'src/register.ts',
},
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
// Keep React as a peer dep — don't bundle it
external: ['react', 'react-dom', 'react-native', 'react-native-web'],
})// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"paths": {
"react-native": ["./node_modules/react-native-web"]
}
},
"include": ["src"]
}The exports field tells bundlers how to resolve your package for different
entry points and module systems.
{
"name": "@myscope/my-widget",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./element": {
"types": "./dist/element.d.ts",
"import": "./dist/element.js",
"require": "./dist/element.cjs"
},
"./register": {
"types": "./dist/register.d.ts",
"import": "./dist/register.js",
"require": "./dist/register.cjs"
}
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"dependencies": {
"@goodwidget/core": "^0.1.0",
"@goodwidget/ui": "^0.1.0",
"@goodwidget/embed": "^0.1.0"
}
}Important: Always put
"types"before"import"and"require"in each export condition. TypeScript resolves them in order.
@goodwidget/ui publishes both native and browser builds from the same package.
Browser-aware bundlers select dist/web.* through the browser export condition;
React Native bundlers select the native build through the react-native condition.
The browser build bundles the Lucide icon modules with GoodWidget's DOM SVG shim,
so consumers of GoodWidget widgets do not need to add a react-native-svg alias.
That shim is shared by the core UI package and any widget package that has a direct SVG implementation; it belongs in the package's browser build, not in Storybook, example-app, or consumer configuration.
Widget packages should continue importing UI components from @goodwidget/ui.
Packages or applications that import @tamagui/lucide-icons or
react-native-svg directly still own those platform-specific dependencies.
# Build
npm run build
# Check the output
ls dist/
# index.js index.cjs index.d.ts
# element.js element.cjs element.d.ts
# register.js register.cjs register.d.ts
# Publish (replace with your scope)
npm publish --access publicInstall and use directly as a React component.
npm install @myscope/my-widget// App.tsx
import { MyWidget } from '@myscope/my-widget'
function App() {
return (
<MyWidget
provider={window.ethereum}
themeOverrides={{
tokens: { color: { primary: '#7B61FF' } },
}}
/>
)
}Add these to your vite.config.ts to ensure Tamagui works correctly:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.TAMAGUI_TARGET': JSON.stringify('web'),
},
resolve: {
alias: {
'react-native': 'react-native-web',
},
},
})The same React component works natively — no @goodwidget/embed needed.
npm install @myscope/my-widget tamagui @tamagui/core// App.tsx
import { MyWidget } from '@myscope/my-widget'
export default function App() {
return (
<MyWidget
provider={walletConnectProvider}
defaultTheme="light"
themeOverrides={{
tokens: { color: { primary: '#E91E63' } },
}}
/>
)
}- Add the Tamagui babel plugin to
babel.config.js:
module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
plugins: [
['@tamagui/babel-plugin', {
components: ['tamagui'],
config: './tamagui.config.ts',
}],
],
}
}- Create a
tamagui.config.tsthat re-exports the GoodWidget config:
import { createGoodWidgetConfig } from '@goodwidget/ui'
const config = createGoodWidgetConfig()
export type AppConfig = typeof config
declare module 'tamagui' {
interface TamaguiCustomConfig extends AppConfig {}
}
export default configSame as above, but configure Metro's resolver.resolveRequest to handle
workspace packages if using a monorepo, and add @tamagui/babel-plugin to
your Babel config.
For pages with no React or build toolchain. You need a bundled version that includes React and all dependencies.
If you publish a UMD/IIFE bundle (see Building a standalone bundle below):
<script src="https://cdn.example.com/my-widget.iife.js"></script>
<my-widget></my-widget>
<script>
// Pass wallet provider
document.querySelector('my-widget').provider = window.ethereum
// Override theme via CSS custom properties
</script>Create a small HTML app that imports the register entry:
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<style>
/* Theme via CSS custom properties — no JS needed */
.branded {
--gw-token-color-primary: #E91E63;
}
</style>
</head>
<body>
<!-- Default theme -->
<my-widget></my-widget>
<!-- Branded via CSS -->
<my-widget class="branded"></my-widget>
<!-- Register the element -->
<script type="module">
import '@myscope/my-widget/register'
</script>
</body>
</html>GoodWidget reads --gw-* CSS custom properties from the host element and
maps them to theme token overrides. The naming convention:
--gw-token-{category}-{name}
| CSS Property | Maps to |
|---|---|
--gw-token-color-primary |
tokens.color.primary |
--gw-token-color-primaryDark |
tokens.color.primaryDark |
--gw-token-space-4 |
tokens.space.4 |
--gw-token-radius-2 |
tokens.radius.2 |
gw-claim-widget {
--gw-token-color-primary: #E91E63;
--gw-token-color-primaryDark: #AD1457;
}For full control (component themes, complex overrides), set properties via JS:
const el = document.querySelector('my-widget')
el.provider = window.ethereum
el.themeOverrides = {
tokens: {
color: { primary: '#7B61FF' },
},
themes: {
light_Card: { background: '#F3E5F5' },
light_Button: { background: '#7B61FF' },
},
}To produce a single .js file that includes React and can be dropped into
any HTML page, create a small bundler project:
// bundle-entry.ts
import '@myscope/my-widget/register'// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
'process.env.TAMAGUI_TARGET': JSON.stringify('web'),
},
resolve: {
alias: { 'react-native': 'react-native-web' },
},
build: {
lib: {
entry: 'bundle-entry.ts',
formats: ['iife'],
name: 'MyWidget',
fileName: 'my-widget',
},
},
})vite build
# Output: dist/my-widget.iife.js// app/page.tsx (App Router)
'use client'
import dynamic from 'next/dynamic'
const MyWidget = dynamic(
() => import('@myscope/my-widget').then(m => m.MyWidget),
{ ssr: false },
)
export default function Page() {
return <MyWidget defaultTheme="light" />
}Add to next.config.js:
const nextConfig = {
transpilePackages: [
'@myscope/my-widget',
'@goodwidget/core',
'@goodwidget/ui',
'tamagui',
'@tamagui/core',
'react-native-web',
],
}Before publishing, verify your widget is fully overridable:
- Every custom styled component uses
createComponent()with a uniquename - The widget wraps itself in
GoodWidgetProviderand acceptsconfig,themeOverrides,provider, anddefaultThemeprops - The Web Component entry uses
createMiniAppElement()withshadow: true - The
registerentry auto-registers with agw-prefixed tag name -
peerDependencieslistsreactandreact-dom(not regular deps) - The
exportsfield has all three entry points withtypesfirst -
tsup.config.tsmarksreact,react-dom,react-nativeas external
Run getThemeManifest() in the browser console to verify all your components
appear in the manifest:
import { getThemeManifest } from '@goodwidget/ui'
console.log(getThemeManifest())
// { components: { Card: {...}, Button: {...}, MyCustomCard: {...} }, tokens: [...] }See packages/claim-widget/ in this repository for a complete, working example
that demonstrates all of the above:
packages/claim-widget/
├── package.json # Three exports: ., ./element, ./register
├── tsconfig.json
├── tsup.config.ts # Builds all three entry points
└── src/
├── index.ts # Barrel: component + element + register
├── ClaimWidget.tsx # React component with GoodWidgetProvider
├── element.ts # createMiniAppElement() wrapper
└── register.ts # Auto-registration side effect
Consumers of this package:
| Consumer | How to use |
|---|---|
examples/react-web/ |
React web app importing ClaimWidget component |
examples/html/ |
Plain HTML page using <gw-claim-widget> element |
examples/expo/ |
Expo React Native app importing GoodWidget components |