Module exports and the import map

Module exports let one plugin import exact code from another plugin. The host resolves app exports through one import map.

The example on this page exports a React component from acme.ui-kit. A second plugin imports and renders that component.

Use this when

  • Share a concrete React component. Export a stable component that another plugin can place in its own UI.
  • Share a pure app helper. Export code that needs React or another app-only module.
  • Publish exact code. Use an export when a winner change must not select different code.
  • Keep one React instance. Let the host import map supply React to all app modules.
  • Understand app reloads. Treat each changed ESM bundle as a new generation.

What you build

The acme.ui-kit plugin exports a StatusBadge component. The acme.dashboard plugin imports it from @acme/bb-ui-kit/components.

The component is exact code. It does not claim a surface and does not join the winner picker.

1. Create the exported module

Put public app code in a small module. Keep factory work in the app artifact.

// src/components/index.tsx
import { Badge } from "@bb/ui";

export type StatusBadgeProps = {
  label: string;
  state: "ready" | "blocked";
};

export function StatusBadge(props: StatusBadgeProps) {
  return (
    <Badge variant={props.state === "ready" ? "default" : "destructive"}>
      {props.label}
    </Badge>
  );
}

An imported module must not register a surface or a service at module load time. Only a tier factory can register behavior.

This rule keeps imports free from hidden lifecycle work. It also lets the loader stage one plugin generation as one unit.

2. Declare the export

Declare each public subpath in bb.plugin.jsonc. The path identifies a built module in the plugin artifact.

// bb.plugin.jsonc
{
  "$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
  "schemaVersion": 2,
  "id": "acme.ui-kit",
  "version": "2.1.0",
  "name": "Acme UI Kit",
  "engines": { "bb": "^2.0.0", "sdk": "^2.0.0" },

  "exports": {
    "./components": "./dist/components.mjs"
  },

  "artifacts": {
    "app": "./dist/app.mjs"
  }
}

The build verifies the manifest and the generated contract.json together. It also records every exported file in artifact.json.

The app artifact can contain a small factory when the plugin also needs app behavior.

// src/app.tsx
import { definePlugin } from "@get-bb/plugin/app";

export default definePlugin((api) => {
  api.log.info("Acme UI Kit is ready");
});

The factory receives a typed api object. The loader cleans all API registrations when this generation stops.

3. Publish the package subpath

Map the same subpath in package.json. Give TypeScript a declaration file and give the browser an ESM file.

// package.json
{
  "name": "@acme/bb-ui-kit",
  "version": "2.1.0",
  "type": "module",
  "peerDependencies": {
    "react": "^19.0.0"
  },
  "exports": {
    "./components": {
      "types": "./dist/types/components.d.ts",
      "browser": "./dist/components.mjs"
    }
  }
}

The package declaration supplies types during author work. The host supplies the installed module bytes at runtime.

First-party plugins use short package names such as @bb/ui. External plugins use their npm package name.

4. Import the exact component

The consumer uses a normal static import.

// src/dashboard-panel.tsx
import { Card, CardContent } from "@bb/ui";
import { StatusBadge } from "@acme/bb-ui-kit/components";

export type DashboardPanelProps = {
  releaseName: string;
  blocked: boolean;
};

export function DashboardPanel(props: DashboardPanelProps) {
  return (
    <Card>
      <CardContent>
        <span>{props.releaseName}</span>
        <StatusBadge
          label={props.blocked ? "Blocked" : "Ready"}
          state={props.blocked ? "blocked" : "ready"}
        />
      </CardContent>
    </Card>
  );
}

The build keeps public app specifiers as ESM imports. It rejects a private package path or an export from another tier.

An app export can import React, @bb/ui, @get-bb/plugin/app, and other declared app exports. It cannot import server or host code.

5. Understand the import map

The host creates an import map from verified artifacts. The map binds each public specifier to one immutable module URL.

The browser fixes an import-map binding after module resolution starts. A new or changed binding therefore needs a page reload.

The exact URL is an implementation detail. This example shows the important bindings.

{
  "imports": {
    "react": "/runtime/react.mjs?h=8f91",
    "react/jsx-runtime": "/runtime/react-jsx-runtime.mjs?h=8f91",
    "@get-bb/plugin/app": "/runtime/plugin-app.mjs?h=29b4",
    "@bb/ui": "/plugins/bb.ui/index.mjs?h=7ac2",
    "@acme/bb-ui-kit/components": "/plugins/acme.ui-kit/components.mjs?h=31de"
  }
}

The builder does not put a private React copy in each app bundle. All app artifacts resolve react through the same map entry.

React context, hooks, and element identity therefore work across plugin module boundaries. The same rule applies to the shared app SDK runtime.

Do not re-export React from your plugin. Import it from react, and let the host supply it.

6. Choose an import or a surface

The rule is: use a contract for a replaceable thing, and import a module for exact code.

Use an import when you want the exact StatusBadge implementation.

import { StatusBadge } from "@acme/bb-ui-kit/components";

export const ReleaseState = () => (
  <StatusBadge label="Ready" state="ready" />
);

Use a surface when you want the implementation that the user selected.

import { Surface } from "@get-bb/plugin/app";

export const ComposerArea = () => (
  <Surface
    id="bb.thread-ui.composer"
    props={{ threadId: "thr_1" }}
  />
);

The bb.thread-ui.composer surface can have a different global winner. The direct StatusBadge import does not follow that winner.

Use a service for a current live provider. A service change can restart required consumers or notify watched consumers.

7. Account for ESM generations

The browser evaluates one ESM URL once. A second import of that URL returns the same module instance.

The loader gives each changed app artifact a fresh, digest-based URL. A changed hash therefore creates a new ESM generation.

The candidate factory runs before cutover. Its registrations stay staged until the full candidate generation becomes ready.

If the candidate fails, the loader discards its registrations. The current plugin generation stays active.

After a successful cutover, the loader stops the old factory and runs its cleanup. The browser can still retain the old ESM module.

Do not keep state in an exported module as a reload mechanism. Put live shared state behind a declared service.

An update to an imported module needs a new consumer module graph. The loader uses a page reload when the current import map cannot accept the new binding.

What happens at runtime

  1. The kernel verifies bb.plugin.jsonc, contract.json, and all artifact digests.
  2. The host builds the import map from the active app artifacts and their declared exports.
  3. The browser loads one shared React and one shared app SDK runtime.
  4. The dashboard module resolves @acme/bb-ui-kit/components through the import map.
  5. React renders StatusBadge inside the dashboard surface.
  6. A changed bundle hash starts a new plugin generation during reload.

Pitfalls

  • Do not use a module export for a replaceable page, panel, or composer. Declare or claim a surface.
  • Do not put registrations or other lifecycle work at the top level of an exported module.
  • Do not import a server export from app code. The build rejects cross-tier imports.
  • Do not bundle React into an app export. A second React copy breaks shared identity.
  • Do not expect old ESM modules to unload. Cleanup must come from the factory lifecycle.
  • Do not use a mutable module singleton as a cross-plugin service. Declare a typed service instead.
  • Do not expect a fork to change a direct import. Imports keep the exact package identity.

See also

  • Guide 7, UI surfaces, for single, list, keyed, and chain contracts.
  • Guide 14, composition, forks, and distribution, for global winner selection.
  • The authoring design, for manifest exports, tier rules, and build artifacts.
  • The bb.ui design, for the shared component kit and its public imports.