KB LabsDocs

Studio Pages

Last updated April 7, 2026


Build Studio pages as Module Federation remotes using the SDK's studio subpaths.

Studio is a React SPA that hosts plugin-authored pages as Module Federation remotes. A plugin declares its pages in the manifest, builds them with a dedicated rspack config, and Studio loads the compiled bundle at runtime when a user navigates to the page's route.

This page covers the mechanics: manifest declaration, build configuration, the SDK surface for plugin pages, runtime loading, and the golden rule about imports.

The golden rule

Plugin code imports only from @kb-labs/sdk and its subpaths. Never from anything else.

  • Runtime code (React components, hooks)@kb-labs/sdk/studio
  • Build-time code (rspack config)@kb-labs/sdk/studio-build
  • Handlers (CLI/REST/workflow)@kb-labs/sdk
  • Testing@kb-labs/sdk/testing

Never import from antd, @ant-design/*, @kb-labs/studio-hooks, @kb-labs/studio-ui-kit, @kb-labs/studio-plugin-tools, @kb-labs/plugin-contracts, @kb-labs/core-platform, react-router-dom, @tanstack/react-query, or any other internal package directly. The SDK re-exports everything you need through its subpaths; internal packages are versioned independently and will break your plugin on every refactor if you couple to them.

This isn't a convention — it's a hard rule. First-party plugins follow it, and any plugin that doesn't will break the moment an internal package bumps a major version.

The shape

Plugin source


rspack config (imports from @kb-labs/sdk/studio-build)


dist/widgets/remoteEntry.js    ← the MF remote bundle


Served by REST API under /plugins/<name>/widgets/


Studio loads the bundle at runtime:
  import('commitPlugin/CommitOverview')


React component mounts at /p/commit
  (imports from @kb-labs/sdk/studio)

Three things need to line up:

  1. Manifest declaration — the plugin's manifest.ts has a studio block telling Studio which pages exist and where to mount them.
  2. rspack config — builds each page as an exposed module in a Module Federation remote, using createStudioRemoteConfig from @kb-labs/sdk/studio-build.
  3. Page components — React components that import everything they need from @kb-labs/sdk/studio.

Step 1 — Declare the studio block in the manifest

Every plugin that ships Studio pages has a studio field in ManifestV3:

TypeScript
studio?: {
  version: 2;
  remoteName: string;
  pages: StudioPageEntry[];
  menus?: StudioMenuEntry[];
}

Example from kb-labs-commit-plugin:

TypeScript
import type { StudioConfig } from '@kb-labs/sdk/studio';
 
// ... in your manifest
studio: {
  version: 2 as const,
  remoteName: 'commitPlugin',
  pages: [
    {
      id: 'commit.overview',
      title: 'Commit',
      icon: 'GitlabOutlined',
      route: '/p/commit',
      entry: './CommitOverview',
      order: 1,
    },
  ],
  menus: [
    {
      id: 'commit',
      label: 'Commit',
      icon: 'GitlabOutlined',
      target: 'commit.overview',
      order: 30,
    },
  ],
}
  • version: 2 is mandatory — it's a literal type, not a number. There is no v1. Don't try to use 1.
  • remoteName is the Module Federation remote name, used by Studio to import() the bundle. It must match the name passed to createStudioRemoteConfig() in step 2.
  • pages[i].entry is the exposed module path — ./CommitOverview matches the key in exposes in the rspack config.
  • pages[i].route is where the page mounts in the Studio URL space. By convention first-party plugins use /p/<plugin-name>, but any path works.
  • pages[i].icon is an Ant Design icon name (e.g. GitlabOutlined, ThunderboltOutlined). Icons resolve on the Studio host — your plugin doesn't render them.
  • menus registers entries in Studio's sidebar; target references a page id.

The manifest types (StudioConfig, StudioPageEntry, StudioMenuEntry) are re-exported from @kb-labs/sdk/studio — don't import them from @kb-labs/plugin-contracts directly.

See Manifest Reference → studio for the full schema.

Step 2 — Set up the rspack build

The SDK exposes the rspack-config helper as a separate subpath to keep build-time dependencies out of your runtime bundle:

rspack.studio.config.mjs:

JavaScript
import { createStudioRemoteConfig } from '@kb-labs/sdk/studio-build';
 
export default await createStudioRemoteConfig({
  name: 'commitPlugin',
  exposes: {
    './CommitOverview': './src/studio/pages/CommitOverview.tsx',
    './CommitPlan':     './src/studio/pages/CommitPlan.tsx',
  },
});

package.json:

JSON
{
  "scripts": {
    "build:studio": "rspack build --config rspack.studio.config.mjs"
  },
  "devDependencies": {
    "@kb-labs/sdk": "^1.5.0",
    "@rspack/cli": "^1.0.0"
  }
}

You only need the SDK itself. The helper pulls in @module-federation/enhanced and configures everything else transitively — you don't depend on those packages directly.

What createStudioRemoteConfig does

  • Sets up ModuleFederationPlugin with your name and exposes.
  • Merges STUDIO_SHARED_DEPS into the shared scope (see below).
  • Configures builtin:swc-loader for TypeScript + JSX with react.runtime: 'automatic'.
  • Sets up CSS and CSS Modules loaders.
  • Outputs to dist/widgets/ with publicPath: 'auto'.
  • Validates that your plugin pins React 18 (see the Callout below).

You can override anything by passing more fields:

JavaScript
import { createStudioRemoteConfig } from '@kb-labs/sdk/studio-build';
 
export default await createStudioRemoteConfig({
  name: 'commitPlugin',
  exposes: { './CommitOverview': './src/studio/pages/CommitOverview.tsx' },
  filename: 'myRemoteEntry.js',     // default 'remoteEntry.js'
  outputDir: 'dist/ui',             // default 'dist/widgets'
  shared: {
    // Add or override shared deps if you really need to
    dayjs: { singleton: true, requiredVersion: '^1.11.0' },
  },
});

Studio host runs React 18. If your plugin bundles React 19, Module Federation can't reconcile the element types at runtime — you get a "Objects are not valid as a React child" crash the moment the page mounts. The helper validates the React version in your node_modules and throws at build time with the exact fix. Pin React 18 in your plugin's devDependencies:

JSON
{
  "devDependencies": {
    "react":     "^18.3.1",
    "react-dom": "^18.3.1"
  }
}

Then pnpm install and rebuild.

Step 3 — Shared dependencies (how the rule works)

The host Studio provides shared copies of React, Ant Design, the routing library, TanStack Query, and the Studio SDK packages. Plugin remotes don't ship their own — they reuse the host's via Module Federation's shared scope. createStudioRemoteConfig wires this up automatically.

The shared scope includes: react, react-dom, react-router-dom, antd, @ant-design/icons, @tanstack/react-query, zustand, @kb-labs/studio-hooks, @kb-labs/studio-event-bus, @kb-labs/studio-ui-kit, @kb-labs/studio-ui-core, @kb-labs/sdk.

But from your plugin code's perspective, you don't import any of these directly. You import from @kb-labs/sdk/studio, which re-exports the hooks, types, design tokens, and UIKit components from the internal packages. The shared scope makes that re-export efficient (no duplication at runtime); the golden rule makes it safe (no coupling to internal package versions).

Step 4 — Write a page component

src/studio/pages/CommitOverview.tsx:

TSX
import {
  // Hooks
  useData,
  useNavigation,
  useNotification,
  // UIKit components (re-exported from the SDK)
  Card,
  Button,
  Space,
  Title,
  Text,
  // Types
  type PageContext,
} from '@kb-labs/sdk/studio';
import type { CommitStatus } from '@kb-labs/commit-contracts';
 
export default function CommitOverview() {
  const { navigate } = useNavigation();
  const { success, error: notifyError } = useNotification();
  const { data, isLoading, error } = useData<CommitStatus>('/api/v1/plugins/commit/status');
 
  if (isLoading) return <Text>Loading…</Text>;
  if (error)     return <Text type="danger">Error: {error.message}</Text>;
 
  return (
    <Card>
      <Space direction="vertical" style={{ width: '100%' }}>
        <Title level={3}>Commit Overview</Title>
        <Text>Plan: {data?.plan ?? 'none'}</Text>
        <Button
          type="primary"
          onClick={() => {
            navigate('/p/commit/plan');
            success('Navigating to plan…');
          }}
        >
          Generate plan
        </Button>
      </Space>
    </Card>
  );
}

Key points:

  • Single import from @kb-labs/sdk/studio for hooks, components, and types. Everything else is off-limits.
  • Default export the component. The entry in your manifest (./CommitOverview) points to the file; the default export is what Studio mounts.
  • Don't import antd directly. If a component you need isn't in UIKit, either compose from what's there, or file an issue against UIKit.
  • REST calls go through the gateway. The /api/v1/plugins/commit/status path is the REST route declared in your plugin's manifest. The useData hook's fetch wrapper automatically resolves it against the gateway.
  • Your own plugin contracts are fine — importing CommitStatus from @kb-labs/commit-contracts is expected. The rule is about platform-internal packages, not your own types.

Step 5 — Runtime loading (how Studio finds your page)

At Studio startup, Studio fetches the studio registry (GET /api/v1/studio/registry) which returns the list of installed plugins and their studio manifests. For each plugin, Studio:

  1. Registers the remote with Module Federation using remoteName and the URL of the remoteEntry.js bundle.
  2. Walks pages[] and registers each one with Studio's router at the declared route.
  3. Walks menus[] and adds them to the sidebar.

When a user navigates to a page route, Studio dynamically imports the module from the remote. The returned component is wrapped in a page container (which provides the page context and error boundary) and mounted.

If the load fails — network error, bundle missing, version mismatch — Studio renders a plugin error UI with the failure details instead of crashing the whole app.

Your plugin code doesn't see any of this. You just write a component and export it as the entry module.

Step 6 — What's in @kb-labs/sdk/studio

All of this is importable from @kb-labs/sdk/studio. Source: platform/kb-labs-sdk/packages/sdk/src/studio/index.ts.

Hooks

HookPurpose
usePage()Current page context — ID, title, navigation state
useEventBus(topic, handler)Subscribe to / publish on the in-app event bus
useData<T>(url, options?)GET with caching
useMutateData<T, V>(url, options?)POST / PUT / PATCH / DELETE with optimistic updates
useInfiniteData<T>(url, options?)Paginated / infinite-scroll data
useSSE<T>(url, options?)Subscribe to a server-sent events stream
useWebSocket<T>(url, options?)Bidirectional WebSocket with auto-reconnect
usePermissions()Current user's permissions (for showing/hiding UI)
useNavigation()Programmatic navigation — navigate, back, replace
useNotification()Toast notifications with info / success / warning / error
useTheme()Current theme tokens (light/dark, semantic colors)

These wrap the underlying Studio hooks and handle cross-cutting concerns automatically: correlation headers, auth tokens, retry policy, loading states, error boundaries.

UIKit components

Everything UIKit exports is re-exported from @kb-labs/sdk/studio via export * from '@kb-labs/studio-ui-kit'. That includes tables, forms, layouts, buttons, cards, metric displays, diff viewers, and the rest of the component library. Import them directly from the SDK — no need to know that UIKit exists as a separate package.

If a component you need isn't in UIKit, don't reach into antd directly. Either compose it from existing pieces, or open an issue asking UIKit to add the primitive. The golden rule exists for a reason — UIKit evolves the component set over time, and plugins need a stable surface.

Design tokens

TokenPurpose
colorsRaw color palette
spacingSpacing scale
typographyFont sizes, weights, line heights
radiusBorder radius scale
shadowsShadow scale
lightTheme, darkThemeFull theme objects
lightSemanticColors, darkSemanticColorsSemantic color sets

Use these when you need custom styling that matches the Studio theme.

Types

TypeSource
PageContext, UseDataOptions, UseDataReturn, UseMutateDataReturn, …Studio hook types
NotificationType, SemanticTokensUI primitives
EventMeta, EventHandlerEvent bus
StudioConfig, StudioPageEntry, StudioMenuEntryManifest types

All re-exported from the SDK — don't import them from @kb-labs/plugin-contracts or @kb-labs/studio-* directly.

DevTools

devToolsStore, GenericChannel, and related types are exported for plugin developers who want to integrate with the Studio DevTools panel. Optional.

Example: a complete plugin page

TSX
import {
  useData,
  useMutateData,
  useNotification,
  useNavigation,
  Card,
  Button,
  Space,
  Title,
  Text,
  Alert,
  Table,
} from '@kb-labs/sdk/studio';
import type { CommitPlan } from '@kb-labs/commit-contracts';
 
export default function CommitPlanView() {
  const { navigate } = useNavigation();
  const { success, error: notifyError } = useNotification();
 
  const { data: plan, isLoading } = useData<CommitPlan>('/api/v1/plugins/commit/plan');
 
  const { mutate: apply, isPending } = useMutateData<void, { planId: string }>(
    '/api/v1/plugins/commit/apply',
    { method: 'POST' },
  );
 
  if (isLoading) return <Text>Loading plan…</Text>;
  if (!plan)     return <Alert type="info" message="No plan. Generate one first." />;
 
  const onApply = () => {
    apply(
      { planId: plan.id },
      {
        onSuccess: () => {
          success('Plan applied');
          navigate('/p/commit');
        },
        onError: (err) => notifyError(`Failed: ${err.message}`),
      },
    );
  };
 
  return (
    <Card>
      <Space direction="vertical" style={{ width: '100%' }}>
        <Title level={3}>Commit Plan</Title>
        <Text type="secondary">{plan.commits.length} commits</Text>
        <Table
          dataSource={plan.commits}
          columns={[
            { title: 'Message', dataIndex: 'message', key: 'message' },
            { title: 'Files',   dataIndex: 'filesCount', key: 'files' },
          ]}
        />
        <Button type="primary" loading={isPending} onClick={onApply}>
          Apply commits
        </Button>
      </Space>
    </Card>
  );
}

One import, one default export, no internal package coupling.

What you CAN'T do

A checklist of things that look fine but break the rule:

  • import { Button } from 'antd'
  • import { useNavigate } from 'react-router-dom'
  • import { useQuery } from '@tanstack/react-query'
  • import { useData } from '@kb-labs/studio-hooks'
  • import { PageLayout } from '@kb-labs/studio-ui-kit'
  • import { colors } from '@kb-labs/studio-ui-core'
  • import type { StudioConfig } from '@kb-labs/plugin-contracts'
  • import { createStudioRemoteConfig } from '@kb-labs/studio-plugin-tools'

All of them have an equivalent through @kb-labs/sdk or its subpaths. Use those.

Bundle the plugin and verify

Bash
pnpm --filter your-plugin run build:studio
ls dist/widgets/
# remoteEntry.js + chunks for each page

Then link the plugin into your workspace:

Bash
pnpm kb marketplace link ./path/to/your-plugin
pnpm kb marketplace clear-cache

Restart Studio (or hard-reload the browser — Module Federation remotes are cached aggressively) and your pages should appear in the sidebar.

Troubleshooting

Plugin built but doesn't appear in Studio.

  1. Make sure dist/widgets/remoteEntry.js exists.
  2. Verify the plugin is in .kb/marketplace.lock (check with pnpm kb marketplace list).
  3. Clear the registry cache: pnpm kb marketplace clear-cache.
  4. Hard-reload Studio in the browser.

"Objects are not valid as a React child" at page mount.

You bundled React 19 instead of pinning 18. See the Callout above.

"Shared module is not available for eager consumption".

A shared dependency version mismatch. Check that your package.json pins the versions listed in the shared scope above — react@^18.3.0, antd@^5.21.0, etc.

"Cannot find module '@kb-labs/sdk/studio'".

Make sure you're on SDK 1.5+ and your tsconfig.json has moduleResolution: "bundler" or "node16". The /studio subpath relies on exports in package.json which older module resolution modes don't understand.

Studio Pages — KB Labs Docs