Getting Started with React PKL
This guide will walk you through creating your first plugin system with React PKL.
Table of Contents
- Installation
- Understanding the Architecture
- Creating Your SDK
- Integrating with Your React App
- Writing Your First Plugin
- Building Plugins
- Next Steps
Installation
React PKL is a monorepo with multiple packages. For a basic setup, you'll need:
# Install the core package
npm install @pkl.js/react react
# Install the SDK package for building plugins (dev dependency)
npm install --save-dev @pkl.js/react-sdk
Understanding the Architecture
React PKL uses a three-layer architecture:
┌─────────────────────────────────────┐
│ Your Plugin Developers │
│ (Uses your custom SDK) │
└─────────────┬───────────────────────┘
│
┌─────────────▼───────────────────────┐
│ Your Custom SDK │
│ (Built on React PKL) │
└─────────────┬───────────────────────┘
│
┌─────────────▼───────────────────────┐
│ React PKL Core │
│ (Plugin management system) │
└─────────────────────────────────────┘
Important: Plugin developers don't use React PKL directly. They use your custom SDK that you build on top of React PKL.
Creating Your SDK
Step 1: Define Your Service Interfaces
First, define the service interfaces you'll expose to plugins. Each service will have its own React context and hook:
// my-app-sdk/src/app-context.ts
/**
* Notification service for showing messages to users
*/
export interface NotificationService {
show(message: string, type?: 'info' | 'success' | 'warning' | 'error'): string;
dismiss(id: string): void;
}
/**
* Router service for navigation
*/
export interface RouterService {
navigate(path: string): void;
getCurrentPath(): string;
}
/**
* User information
*/
export interface UserInfo {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
/**
* Logger service for debugging
*/
export interface LoggerService {
log(message: string): void;
warn(message: string): void;
error(message: string): void;
}
Architecture Note: Instead of bundling all services into a single context, we create separate service interfaces that will each have their own React context. This keeps the plugin infrastructure minimal and allows plugins to opt-in to only the services they need.
Step 2: Define Layout Interface
Define a layout interface that describes all the slots in your application:
// my-app-sdk/src/app-layout.ts
/**
* Layout shape for the app.
* Each property represents a slot where plugins can inject content.
*/
export interface AppLayout {
/** Items injected into the top toolbar */
toolbar: React.ReactNode[];
/** Items injected into the left sidebar */
sidebar: React.ReactNode[];
/** Widgets added to the main dashboard */
dashboard: React.ReactNode[];
/** Sections added to the Settings page */
settings: React.ReactNode[];
}
Step 3: Create Layout Context and Slots
Create a layout context and slot components using React PKL's utilities:
// my-app-sdk/src/slots.ts
import { createLayoutContext, createSlot } from '@pkl.js/react/react';
import type { AppLayout } from './app-layout.js';
/**
* Layout context provides global access to the app's slot state.
*/
export const {
LayoutProvider: AppLayoutProvider,
useLayout: useAppLayout,
useLayoutController: useAppLayoutController,
} = createLayoutContext<AppLayout>();
// Create slot components for each extension point
export const {
Provider: ToolbarSlotProvider,
Item: ToolbarItem,
} = createSlot<AppLayout, 'toolbar'>('toolbar', useAppLayoutController);
export const {
Provider: SidebarSlotProvider,
Item: SidebarItem,
} = createSlot<AppLayout, 'sidebar'>('sidebar', useAppLayoutController);
export const {
Provider: DashboardSlotProvider,
Item: DashboardItem,
} = createSlot<AppLayout, 'dashboard'>('dashboard', useAppLayoutController);
export const {
Provider: SettingsSlotProvider,
Item: SettingsItem,
} = createSlot<AppLayout, 'settings'>('settings', useAppLayoutController);
How it works:
createSlotreturns{ Provider, Item }. The Provider must wrap the part of your app where the slot is rendered. The Item component is what plugins use to register content.
Step 4: Define Layout Slot Components
Layout slots are themeable components that use useAppLayout() to get their content:
// my-app-sdk/src/layout-slots.tsx
import { createLayoutSlot } from '@pkl.js/react/react';
import { useAppLayout } from './slots.js';
/**
* App header with toolbar items
*/
export const AppHeader = createLayoutSlot(() => {
const { toolbar } = useAppLayout();
return (
<header style={{ padding: '1rem', borderBottom: '1px solid #ccc' }}>
<nav style={{ display: 'flex', gap: '1rem' }}>
{toolbar}
</nav>
</header>
);
});
/**
* App sidebar with navigation items
*/
export const AppSidebar = createLayoutSlot(() => {
const { sidebar } = useAppLayout();
return (
<aside style={{ width: '250px', padding: '1rem' }}>
<nav>{sidebar}</nav>
</aside>
);
});
/**
* Dashboard content area
*/
export const AppDashboard = createLayoutSlot(() => {
const { dashboard } = useAppLayout();
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '1rem' }}>
{dashboard}
</div>
);
});
Step 5: Create SDK Helpers
Make it easy for plugin developers to use your SDK:
// my-app-sdk/src/plugin.ts
import { PluginHost, PluginInfrastructure } from '@pkl.js/react';
import type { PluginModule } from '@pkl.js/react';
// Type alias for your plugins - uses minimal PluginInfrastructure
export type AppPlugin = PluginModule<PluginInfrastructure>;
// Helper function for type inference
export function definePlugin(plugin: AppPlugin): AppPlugin {
return plugin;
}
// Factory for creating the plugin host (v0.3.0)
export function createAppHost() {
return new PluginHost<PluginInfrastructure>();
}
What is PluginInfrastructure? It's a minimal context type exported by
@pkl.js/reactcontaining only the essential plugin system infrastructure:host(PluginHost),_resources(ResourceTracker), and_pluginId(string). Your app services are provided separately via React context.
Step 6: Create React Service Contexts
Provide each service as a separate React context with provider and hook:
// my-app-sdk/src/react/services.tsx
import { createContext, useContext, type ReactNode } from 'react';
import type { NotificationService, RouterService, UserInfo, LoggerService } from '../app-context.js';
// Notifications Context
const NotificationsContext = createContext<NotificationService | null>(null);
NotificationsContext.displayName = 'NotificationsContext';
export function NotificationsProvider({
value,
children
}: {
value: NotificationService;
children: ReactNode;
}) {
return <NotificationsContext.Provider value={value}>{children}</NotificationsContext.Provider>;
}
export function useNotifications(): NotificationService {
const ctx = useContext(NotificationsContext);
if (!ctx) throw new Error('useNotifications must be used within NotificationsProvider');
return ctx;
}
// Router Context
const RouterContext = createContext<RouterService | null>(null);
RouterContext.displayName = 'RouterContext';
export function RouterProvider({
value,
children
}: {
value: RouterService;
children: ReactNode;
}) {
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
}
export function useRouter(): RouterService {
const ctx = useContext(RouterContext);
if (!ctx) throw new Error('useRouter must be used within RouterProvider');
return ctx;
}
// User Context
const UserContext = createContext<UserInfo | null>(null);
UserContext.displayName = 'UserContext';
export function UserProvider({
value,
children
}: {
value: UserInfo | null;
children: ReactNode;
}) {
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
export function useUser(): UserInfo | null {
const ctx = useContext(UserContext);
return ctx; // null is a valid value for logged-out state
}
// Logger Context
const LoggerContext = createContext<LoggerService | null>(null);
LoggerContext.displayName = 'LoggerContext';
export function LoggerProvider({
value,
children
}: {
value: LoggerService;
children: ReactNode;
}) {
return <LoggerContext.Provider value={value}>{children}</LoggerContext.Provider>;
}
export function useLogger(): LoggerService {
const ctx = useContext(LoggerContext);
if (!ctx) throw new Error('useLogger must be used within LoggerProvider');
return ctx;
}
Why separate contexts? This approach keeps the plugin infrastructure minimal and allows plugins to selectively use only the services they need via hooks. It's easier to extend, test, and maintain.
Step 6.5: Create Typed Plugin Hooks
Create a simple hooks file that uses createTypedHooks to generate typed plugin management hooks:
// my-app-sdk/src/react/hooks.ts
import { createTypedHooks, PluginInfrastructure } from '@pkl.js/react/react';
// Create typed hooks for PluginInfrastructure
export const {
usePlugins: useAppPlugins,
useEnabledPlugins: useEnabledAppPlugins,
usePlugin: useAppPlugin,
usePluginMeta: useAppPluginMeta,
usePluginHost: useAppPluginHost,
useCurrentPlugin: useCurrentAppPlugin,
} = createTypedHooks<PluginInfrastructure>();
Note: The
createTypedHooks<TContext>()factory automatically creates typed wrappers for all plugin management hooks. Since we're usingPluginInfrastructure(the minimal context), these hooks are focused purely on plugin lifecycle management, not app services.
Step 6.6: Create Plugin Provider
Create a simple re-export of the core PluginProvider:
// my-app-sdk/src/react/provider.tsx
export { PluginProvider as AppPluginProvider } from '@pkl.js/react/react';
Simple! Since we're using the core infrastructure and separate service contexts, we don't need a custom provider wrapper.
Step 7: Export Everything
Create the main export files for your SDK:
// my-app-sdk/src/index.ts - Main SDK exports
export type { NotificationService, RouterService, LoggerService, UserInfo } from './app-context.js';
export type { AppLayout } from './app-layout.js';
export {
AppLayoutProvider,
useAppLayout,
useAppLayoutController,
ToolbarSlotProvider,
ToolbarItem,
SidebarSlotProvider,
SidebarItem,
DashboardSlotProvider,
DashboardItem,
SettingsSlotProvider,
SettingsItem,
} from './slots.js';
export {
AppHeader,
AppSidebar,
AppDashboard,
} from './layout-slots.js';
export {
definePlugin,
createAppHost,
type AppPlugin,
} from './plugin.js';
// Re-export for convenience
export type { PluginMeta, PluginInfrastructure } from '@pkl.js/react';
// my-app-sdk/src/react/index.ts - React-specific exports
export { AppPluginProvider } from './provider.js';
// Export plugin management hooks
export {
useAppPlugins,
useEnabledAppPlugins,
useAppPlugin,
useAppPluginMeta,
useAppPluginHost,
useCurrentAppPlugin,
} from './hooks.js';
// Export service providers and hooks
export {
NotificationsProvider,
useNotifications,
RouterProvider,
useRouter,
UserProvider,
useUser,
LoggerProvider,
useLogger,
} from './services.js';
useRouter,
UserProvider,
useUser,
LoggerProvider,
useLogger,
} from './services.js';
// Re-export PluginEntrypoints for rendering plugin UI
export { PluginEntrypoints } from '@pkl.js/react/react';
Note: The main
index.tsexports types, slots, and plugin helpers. Thereact/index.tsexports React-specific hooks, providers, and service contexts. This modular structure allows plugin developers to import exactly what they need.
Step 8: Configure package.json
{
"name": "my-app-sdk",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"import": "./dist/react/index.js"
}
},
"peerDependencies": {
"@pkl.js/react": "^0.3.0",
"react": ">=18.0.0"
}
}
Integrating with Your React App
Step 1: Create Service Implementations
Implement the service interfaces you defined:
// app/src/services/notifications.ts
import type { NotificationService } from 'my-app-sdk';
export function createNotificationService(): NotificationService {
const listeners = new Set<() => void>();
let notifications: Array<{ id: string; message: string; type: string }> = [];
return {
show(message: string, type = 'info') {
const id = Math.random().toString(36);
notifications.push({ id, message, type });
listeners.forEach(fn => fn());
// Auto-dismiss after 3 seconds
setTimeout(() => this.dismiss(id), 3000);
return id;
},
dismiss(id: string) {
notifications = notifications.filter(n => n.id !== id);
listeners.forEach(fn => fn());
},
};
}
// app/src/services/router.ts
import type { RouterService } from 'my-app-sdk';
export function createRouterService(
navigate: (path: string) => void,
getCurrentPath: () => string
): RouterService {
return { navigate, getCurrentPath };
}
// app/src/services/logger.ts
import type { LoggerService } from 'my-app-sdk';
export function createLoggerService(): LoggerService {
return {
log: (msg) => console.log(`[App] ${msg}`),
warn: (msg) => console.warn(`[App] ${msg}`),
error: (msg) => console.error(`[App] ${msg}`),
};
}
Step 2: Set Up the Plugin Host and Providers
Compose your app with service providers and the plugin system:
// app/src/App.tsx
import { useState, useEffect, useMemo } from 'react';
import {
AppPluginProvider,
AppLayoutProvider,
NotificationsProvider,
RouterProvider,
UserProvider,
LoggerProvider,
ToolbarSlotProvider,
SidebarSlotProvider,
DashboardSlotProvider,
AppHeader,
AppSidebar,
AppDashboard,
} from 'my-app-sdk/react';
import { createAppHost } from 'my-app-sdk';
import { createNotificationService } from './services/notifications.js';
import { createRouterService } from './services/router.js';
import { createLoggerService } from './services/logger.js';
function App() {
// Create plugin host (no context needed)
const host = useMemo(() => createAppHost(), []);
// Create service instances
const notifications = useMemo(() => createNotificationService(), []);
const router = useMemo(() => createRouterService(
(path) => console.log('Navigate to:', path),
() => window.location.pathname
), []);
const logger = useMemo(() => createLoggerService(), []);
const [user, setUser] = useState(null);
// Load plugins on mount
useEffect(() => {
async function loadPlugins() {
try {
// Load plugins (fetch from server, local imports, etc.)
const pluginModules = await Promise.all([
import('./plugins/hello.js'),
import('./plugins/theme-toggle.js'),
]);
pluginModules.forEach(module => {
host.register(module.default);
});
// Enable all plugins by default
host.getPlugins().forEach(plugin => {
host.enable(plugin.id);
});
} catch (error) {
console.error('Failed to load plugins:', error);
}
}
loadPlugins();
}, [host]);
return (
<NotificationsProvider value={notifications}>
<RouterProvider value={router}>
<UserProvider value={user}>
<LoggerProvider value={logger}>
<AppPluginProvider host={host}>
<AppLayoutProvider>
<ToolbarSlotProvider>
<SidebarSlotProvider>
<DashboardSlotProvider>
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<AppHeader />
<div style={{ display: 'flex', flex: 1 }}>
<AppSidebar />
<main style={{ flex: 1, padding: '2rem' }}>
<AppDashboard />
</main>
</div>
</div>
</DashboardSlotProvider>
</SidebarSlotProvider>
</ToolbarSlotProvider>
</AppLayoutProvider>
</AppPluginProvider>
</LoggerProvider>
</UserProvider>
</RouterProvider>
</NotificationsProvider>
);
}
export default App;
Key Points:
createAppHost()no longer needs a context parameter- Each service has its own provider wrapping the plugin system
- Plugins access services via hooks (
useNotifications(),useRouter(), etc.)- The plugin host is passed to
AppPluginProvider- Slot providers wrap the areas where plugins can inject content
Writing Your First Plugin
Now plugin developers can create plugins using your SDK:
// plugins/hello-plugin/src/index.tsx
import { definePlugin } from 'my-app-sdk';
import { ToolbarItem, useNotifications, useLogger } from 'my-app-sdk/react';
/**
* A simple Hello World plugin
*/
export default definePlugin({
meta: {
id: 'com.example.hello',
name: 'Hello World Plugin',
version: '1.0.0',
description: 'A simple greeting plugin',
},
activate(infra) {
// Called when the plugin is enabled
// infra contains: host, _resources, _pluginId
console.log('[HelloPlugin] Activated!');
// Note: Can't use React hooks in activate()
// Use hook-based side effects in components instead
},
deactivate() {
// Called when the plugin is disabled
console.log('[HelloPlugin] Deactivated!');
},
entrypoint: () => (
<ToolbarItem>
<HelloButton />
</ToolbarItem>
),
});
// Components can use service hooks
const HelloButton = () => {
const notifications = useNotifications();
const logger = useLogger();
return (
<button onClick={() => {
notifications.show('Hello from the plugin!', 'info');
logger.log('Hello button clicked');
}}>
👋 Hello
</button>
);
};
Key Points:
- Plugins receive
PluginInfrastructure(not full app context) inactivate()- Components use service hooks:
useNotifications(),useRouter(),useUser(),useLogger()- Each plugin only imports the hooks it needs
- Services are optional - plugins can work without them
Building Plugins
Development Mode
During development, you can use the plugin directly without building:
// In your app during development
await host.add(() => import('./plugins/hello-plugin/src/index.tsx'), { enabled: true });
Production Build
For production, use the SDK build tool:
// plugins/hello-plugin/build.ts
import { buildPlugin } from '@pkl.js/react-sdk';
await buildPlugin({
entry: './src/index.tsx',
outDir: './dist',
meta: {
id: 'com.example.hello',
name: 'Hello World Plugin',
version: '1.0.0',
},
formats: ['esm'],
minify: true,
sourcemap: true,
});
console.log('Plugin built successfully!');
Run it:
node build.ts
The plugin will be bundled to dist/index.js and can be loaded:
await host.add(() => import('/plugins/hello-plugin/dist/index.js'), { enabled: true });
Next Steps
Now that you have a working plugin system:
- Add More Features - Expand your
AppContextwith more services - Create More Slots - Define additional extension points
- Resource Management - Implement automatic cleanup for routes, timers, etc.
- Remote Plugins - Set up a plugin manifest server for client mode
- Plugin Marketplace - Build a UI for managing plugins
- Documentation - Document your SDK for plugin developers
Check out these guides:
Common Patterns
Lazy Loading Plugins
const pluginsToLoad = ['plugin-a', 'plugin-b', 'plugin-c'];
for (const pluginId of pluginsToLoad) {
await host.add(
() => import(`./plugins/${pluginId}.js`),
{ enabled: true }
);
}
Conditional Plugin Loading
// Only load admin plugins for admin users
if (user.role === 'admin') {
await host.add(() => import('./plugins/admin-panel.js'), { enabled: true });
}
// Only load on certain routes
if (location.pathname.startsWith('/dashboard')) {
await host.add(() => import('./plugins/dashboard-widgets.js'), { enabled: true });
}
Plugin with Custom Settings UI
If you want plugins to have configurable settings, you can use the SettingsItem slot:
import { definePlugin, SettingsItem, useAppContext } from 'my-app-sdk';
export default definePlugin({
meta: {
id: 'com.example.configurable',
name: 'Configurable Plugin',
version: '1.0.0',
},
entrypoint: () => (
<SettingsItem>
<PluginSettingsPanel />
</SettingsItem>
),
});
function PluginSettingsPanel() {
const context = useAppContext();
const [apiKey, setApiKey] = useState('');
const handleSave = async () => {
// You could save settings via your app's API
await context.api.post('/plugin-settings/com.example.configurable', { apiKey });
context.notifications.show('Settings saved!', 'success');
};
return (
<div className="plugin-settings">
<h3>Plugin Settings</h3>
<input
type="text"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="API Key"
/>
<button onClick={handleSave}>Save</button>
</div>
);
}
Troubleshooting
Plugin Not Loading
- Check the browser console for errors
- Verify the plugin ID is unique
- Ensure all peer dependencies are installed
- Check that the plugin exports a default module
Slot Content Not Rendering
- Verify slot Items are wrapped in the correct slot Provider
- Ensure the plugin is enabled:
host.getEnabled() - Check that slot Providers wrap the layout components that render the content
- Verify layout slot components use
useAppLayout()to retrieve content
Type Errors
- Ensure your SDK properly exports types
- Plugin developers need to import types from your SDK, not
@pkl.js/react - Check
tsconfig.jsonhas proper module resolution
Context Not Available
- Verify context is passed to
PluginHost(v0.2.0) when creating it withcreateAppHost(context) - Check that
activatesignature matches:activate(context: AppContext) - Make sure context is set before plugins are enabled
- Ensure
PluginProviderreceives bothmanagerandcontextprops