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:
- Manifest declaration — the plugin's
manifest.tshas astudioblock telling Studio which pages exist and where to mount them. - rspack config — builds each page as an exposed module in a Module Federation remote, using
createStudioRemoteConfigfrom@kb-labs/sdk/studio-build. - 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:
studio?: {
version: 2;
remoteName: string;
pages: StudioPageEntry[];
menus?: StudioMenuEntry[];
}Example from kb-labs-commit-plugin:
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: 2is mandatory — it's a literal type, not a number. There is no v1. Don't try to use1.remoteNameis the Module Federation remote name, used by Studio toimport()the bundle. It must match thenamepassed tocreateStudioRemoteConfig()in step 2.pages[i].entryis the exposed module path —./CommitOverviewmatches the key inexposesin the rspack config.pages[i].routeis where the page mounts in the Studio URL space. By convention first-party plugins use/p/<plugin-name>, but any path works.pages[i].iconis an Ant Design icon name (e.g.GitlabOutlined,ThunderboltOutlined). Icons resolve on the Studio host — your plugin doesn't render them.menusregisters entries in Studio's sidebar;targetreferences a pageid.
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:
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:
{
"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
ModuleFederationPluginwith yournameandexposes. - Merges
STUDIO_SHARED_DEPSinto the shared scope (see below). - Configures
builtin:swc-loaderfor TypeScript + JSX withreact.runtime: 'automatic'. - Sets up CSS and CSS Modules loaders.
- Outputs to
dist/widgets/withpublicPath: 'auto'. - Validates that your plugin pins React 18 (see the Callout below).
You can override anything by passing more fields:
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:
{
"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:
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/studiofor hooks, components, and types. Everything else is off-limits. - Default export the component. The
entryin your manifest (./CommitOverview) points to the file; thedefaultexport is what Studio mounts. - Don't import
antddirectly. 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/statuspath is the REST route declared in your plugin's manifest. TheuseDatahook's fetch wrapper automatically resolves it against the gateway. - Your own plugin contracts are fine — importing
CommitStatusfrom@kb-labs/commit-contractsis 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:
- Registers the remote with Module Federation using
remoteNameand the URL of theremoteEntry.jsbundle. - Walks
pages[]and registers each one with Studio's router at the declaredroute. - 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
| Hook | Purpose |
|---|---|
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
| Token | Purpose |
|---|---|
colors | Raw color palette |
spacing | Spacing scale |
typography | Font sizes, weights, line heights |
radius | Border radius scale |
shadows | Shadow scale |
lightTheme, darkTheme | Full theme objects |
lightSemanticColors, darkSemanticColors | Semantic color sets |
Use these when you need custom styling that matches the Studio theme.
Types
| Type | Source |
|---|---|
PageContext, UseDataOptions, UseDataReturn, UseMutateDataReturn, … | Studio hook types |
NotificationType, SemanticTokens | UI primitives |
EventMeta, EventHandler | Event bus |
StudioConfig, StudioPageEntry, StudioMenuEntry | Manifest 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
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
pnpm --filter your-plugin run build:studio
ls dist/widgets/
# remoteEntry.js + chunks for each pageThen link the plugin into your workspace:
pnpm kb marketplace link ./path/to/your-plugin
pnpm kb marketplace clear-cacheRestart 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.
- Make sure
dist/widgets/remoteEntry.jsexists. - Verify the plugin is in
.kb/marketplace.lock(check withpnpm kb marketplace list). - Clear the registry cache:
pnpm kb marketplace clear-cache. - 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.
What to read next
- Manifest Reference → studio — the full
StudioConfigschema. - Services → Studio — Studio internals and how it loads remotes.
- Plugins → REST Routes — how your Studio pages talk back to the REST API.
- Plugins → SDK Hooks — the handler-side SDK (not the Studio-side one).