Frontend plugins provide UI components, pages, routing, and client-side services. They are built using React, React Router, ShadCN UI, and Vite.
Frontend plugins consume oRPC contracts defined in -common packages, enabling type-safe RPC communication with the backend.
The fastest way to create a frontend plugin is using the CLI:
bun run create
Interactive prompts:
frontend as the plugin typemyfeature)This will create a complete plugin structure with:
Generated structure:
plugins/myfeature-frontend/
├── .changeset/
│ └── initial.md # Version changeset
├── package.json # Dependencies
├── tsconfig.json # TypeScript config
├── README.md # Documentation
└── src/
├── index.tsx # Plugin entry point
├── api.ts # Contract-derived API types
└── components/
└── MyFeatureListPage.tsx # Example page
cd plugins/myfeature-frontend
bun install
The generated plugin is a working example. Customize it for your domain:
src/api.ts:
The API types are imported from your common package (no derivation needed):
import { createApiRef } from "@checkstack/frontend-api";
import { MyFeatureClient } from "@checkstack/myfeature-common";
// Re-export types for convenience
export type {
MyItem,
CreateMyItem,
UpdateMyItem,
} from "@checkstack/myfeature-common";
// Use the client type from the common package
export type MyFeatureApi = MyFeatureClient;
export const myFeatureApiRef = createApiRef<MyFeatureApi>("myfeature-api");
src/components/MyFeaturePage.tsx:
import { useEffect, useState } from "react";
import { useApi } from "@checkstack/frontend-api";
import { myFeatureApiRef, type MyItem } from "../api";
import { Button, Card } from "@checkstack/ui";
export const MyFeaturePage = () => {
const api = useApi(myFeatureApiRef);
const [items, setItems] = useState<MyItem[]>([]);
useEffect(() => {
api.getItems().then(setItems);
}, [api]);
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">My Features</h1>
<div className="grid gap-4">
{items.map((item) => (
<Card key={item.id} className="p-4">
<h3>{item.name}</h3>
</Card>
))}
</div>
</div>
);
};
src/index.tsx:
import { createFrontendPlugin } from "@checkstack/frontend-api";
import { MyFeaturePage } from "./components/MyFeaturePage";
import { myFeatureRoutes, pluginMetadata } from "@checkstack/myfeature-common";
export default createFrontendPlugin({
metadata: pluginMetadata,
// Register routes using typed route definitions
routes: [
{
route: myFeatureRoutes.routes.home,
element: <MyFeaturePage />,
},
],
});
# Type check
bun run typecheck
# Lint
bun run lint
That’s it! Your frontend plugin is ready to use.
Note: Make sure you have also created the corresponding
-commonand-backendpackages. See Common Plugin Guidelines and Backend Plugin Development for details.
createFrontendPlugin(config)Creates a frontend plugin with the specified configuration.
Parameters:
metadata (required)Plugin metadata from the common package (contains pluginId).
import { pluginMetadata } from "@checkstack/myplugin-common";
metadata: pluginMetadata
routes (optional)Register pages and their routes using RouteDefinitions from the common package.
import { myRoutes } from "@checkstack/myplugin-common";
routes: [
{
route: myRoutes.routes.home,
element: <ItemListPage />,
title: "Items", // Optional: page title
accessRule: access.itemRead.id, // Optional: required access rule
},
]
extensions (optional)Register components to inject into extension slots.
import { UserMenuItemsSlot } from "@checkstack/frontend-api";
extensions: [
{
id: "myplugin.user-menu.items",
slot: UserMenuItemsSlot,
component: MyUserMenuItems,
},
]
Components access plugin APIs using the usePluginClient hook with TanStack Query integration.
import { usePluginClient } from "@checkstack/frontend-api";
import { MyPluginApi } from "@checkstack/myplugin-common";
export const ItemListPage = () => {
const client = usePluginClient(MyPluginApi);
// Queries - automatic caching, loading states, deduplication
const { data: items, isLoading, error } = client.getItems.useQuery({});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{items?.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
};
import { useQueryClient } from "@checkstack/frontend-api";
export const CreateItemForm = () => {
const client = usePluginClient(MyPluginApi);
const queryClient = useQueryClient();
const createMutation = client.createItem.useMutation({
onSuccess: () => {
// Invalidate cache to refetch list
queryClient.invalidateQueries({ queryKey: ["myplugin"] });
},
});
const handleSubmit = (data: CreateItem) => {
createMutation.mutate(data);
};
return (
<form onSubmit={handleSubmit}>
<Button disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating..." : "Create"}
</Button>
</form>
);
};
The core provides these APIs for use in components:
usePluginClientAccess plugin APIs with TanStack Query integration for automatic caching, loading states, and request deduplication:
import { usePluginClient } from "@checkstack/frontend-api";
import { MyPluginApi } from "@checkstack/myplugin-common";
const client = usePluginClient(MyPluginApi);
// Queries - automatic caching and loading states
const { data, isLoading } = client.getItems.useQuery({});
// Mutations - with cache invalidation
const mutation = client.createItem.useMutation({
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["items"] }),
});
Note: Contracts must include
operationType: "query"oroperationType: "mutation"in their metadata.
[!CAUTION] Never put mutation objects in dependency arrays.
useMutation()returns a new object on every render, which causes infinite re-renders if used inuseEffect,useMemo, oruseCallbackdependencies.
// ❌ BAD - causes infinite loop
const mutation = client.createItem.useMutation();
const callback = useMemo(() => {...}, [mutation]); // mutation changes every render!
// ✅ GOOD - mutation is stable when accessed inside the callback, not as a dependency
const mutation = client.createItem.useMutation();
const callback = useMemo(() => {
return () => mutation.mutate(data);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [/* other stable deps */]);
The ESLint rule checkstack/no-mutation-in-deps catches this pattern.
accessApiRefCheck user access rules.
const accessApi = useApi(accessApiRef);
const canManage = accessApi.useAccess(access.itemManage.id);
if (canManage.allowed) {
// Show management UI
}
authApiRefAccess authentication state.
const authApi = useApi(authApiRef);
const session = authApi.useSession();
if (session.user) {
// User is logged in
}
import { access } from "@checkstack/myplugin-common";
routes: [
{
path: "/config",
element: <ItemConfigPage />,
accessRule: access.itemManage.id,
},
]
Users without access will see an “Access Denied” page.
import { useApi, accessApiRef } from "@checkstack/frontend-api";
import { access } from "@checkstack/myplugin-common";
export const ItemListPage = () => {
const accessApi = useApi(accessApiRef);
const canCreate = accessApi.useAccess(access.itemCreate.id);
return (
<div>
<h1>Items</h1>
{canCreate.allowed && (
<Button onClick={handleCreate}>Create Item</Button>
)}
</div>
);
};
const accessState = accessApi.useAccess(access.itemManage.id);
if (accessState.loading) {
return <LoadingSpinner />;
}
if (!accessState.allowed) {
return <AccessDenied />;
}
return <ItemConfigPage />;
Use components from @checkstack/ui for consistent styling:
import {
Button,
Card,
Input,
Label,
Table,
Dialog,
Select,
Checkbox,
} from "@checkstack/ui";
export const ItemForm = () => {
return (
<Card>
<Label htmlFor="name">Name</Label>
<Input id="name" placeholder="Enter name" />
<Label htmlFor="description">Description</Label>
<Input id="description" placeholder="Enter description" />
<Button type="submit">Save</Button>
</Card>
);
};
Core slots are available from @checkstack/frontend-api:
import {
DashboardSlot,
UserMenuItemsSlot,
UserMenuItemsBottomSlot,
NavbarRightSlot,
NavbarLeftSlot,
} from "@checkstack/frontend-api";
Use the slot: property with a SlotDefinition object:
import { UserMenuItemsSlot } from "@checkstack/frontend-api";
extensions: [
{
id: "myplugin.user-menu.items",
slot: UserMenuItemsSlot,
component: MyUserMenuItems,
},
]
import { DropdownMenuItem } from "@checkstack/ui";
import { Link } from "react-router-dom";
export const MyUserMenuItems = () => {
return (
<>
<DropdownMenuItem asChild>
<Link to="/items/config">Item Settings</Link>
</DropdownMenuItem>
</>
);
};
import { Link, useNavigate } from "react-router-dom";
// Using Link
<Link to="/items/123">View Item</Link>
// Using navigate
const navigate = useNavigate();
navigate("/items/123");
import { useParams } from "react-router-dom";
export const ItemDetailPage = () => {
const { id } = useParams<{ id: string }>();
// Use id to fetch item
};
import { useSearchParams } from "react-router-dom";
export const ItemListPage = () => {
const [searchParams, setSearchParams] = useSearchParams();
const page = searchParams.get("page") || "1";
const filter = searchParams.get("filter") || "";
const handlePageChange = (newPage: number) => {
setSearchParams({ page: newPage.toString(), filter });
};
};
import { useState } from "react";
export const ItemForm = () => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
return (
<form>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
</form>
);
};
import { useEffect, useState } from "react";
import { useApi } from "@checkstack/frontend-api";
import { myPluginApiRef, type Item } from "../api";
export const ItemListPage = () => {
const api = useApi(myPluginApiRef);
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
api
.getItems()
.then(setItems)
.catch(setError)
.finally(() => setLoading(false));
}, [api]);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <ItemList items={items} />;
};
The preferred approach is using usePluginClient with TanStack Query integration. This provides automatic caching, deduplication, loading states, and background refetching.
import { usePluginClient } from "@checkstack/frontend-api";
import { MyPluginApi } from "@checkstack/myplugin-common";
export const ItemListPage = () => {
const client = usePluginClient(MyPluginApi);
// Queries - automatic loading/error states
const { data: items, isLoading, error } = client.getItems.useQuery({});
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <ItemList items={items ?? []} />;
};
// Query with no parameters
const { data, isLoading } = client.getItems.useQuery();
// Query with parameters
const { data: item } = client.getItem.useQuery({ id: itemId });
// Query with options (disable until ready)
const { data } = client.getItems.useQuery({ filter }, {
enabled: !!filter,
staleTime: 60_000, // Cache for 1 minute
});
export const ItemForm = () => {
const client = usePluginClient(MyPluginApi);
const queryClient = useQueryClient();
const createMutation = client.createItem.useMutation({
onSuccess: () => {
// Invalidate cache to refetch list
queryClient.invalidateQueries({ queryKey: ['items'] });
toast.success("Item created!");
},
onError: (error) => {
toast.error(error.message);
},
});
const handleSubmit = (data: CreateItem) => {
createMutation.mutate(data);
};
return (
<form onSubmit={handleSubmit}>
<Button disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating..." : "Create"}
</Button>
</form>
);
};
Note: Contracts must include
operationType: "query"oroperationType: "mutation"in their metadata. Queries expose.useQuery(), mutations expose.useMutation().
import { useState } from "react";
import { Button, Input, Label } from "@checkstack/ui";
export const ItemForm = ({ onSubmit }: { onSubmit: (data: ItemData) => void }) => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ name, description });
};
return (
<form onSubmit={handleSubmit}>
<div>
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button type="submit">Save</Button>
</form>
);
};
import { z } from "zod";
const itemSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
});
export const ItemForm = () => {
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const result = itemSchema.safeParse({ name, description });
if (!result.success) {
const fieldErrors: Record<string, string> = {};
result.error.errors.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0].toString()] = err.message;
}
});
setErrors(fieldErrors);
return;
}
onSubmit(result.data);
};
return (
<form onSubmit={handleSubmit}>
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
{errors.name && <span className="text-red-500">{errors.name}</span>}
</div>
</form>
);
};
import { useApi } from "@checkstack/frontend-api";
import { myPluginApiRef, type Item } from "../api";
export const ItemListPage = () => {
const api = useApi(myPluginApiRef);
const [items, setItems] = useState<Item[]>([]);
const navigate = useNavigate();
useEffect(() => {
api.getItems().then(setItems);
}, [api]);
const handleDelete = async (id: string) => {
await api.deleteItem(id);
setItems(items.filter(item => item.id !== id));
};
return (
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">Items</h1>
<Button onClick={() => navigate("/items/new")}>Create Item</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>
<Button onClick={() => navigate(`/items/${item.id}`)}>
View
</Button>
<Button onClick={() => handleDelete(item.id)} variant="destructive">
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
export const ItemDetailPage = () => {
const { id } = useParams<{ id: string }>();
const api = useApi(myPluginApiRef);
const [item, setItem] = useState<Item | null>(null);
useEffect(() => {
if (id) {
api.getItem(id).then(setItem);
}
}, [id, api]);
if (!item) return <LoadingSpinner />;
return (
<div className="p-6">
<h1 className="text-2xl font-bold">{item.name}</h1>
<p>{item.description}</p>
</div>
);
};
export const ItemEditPage = () => {
const { id } = useParams<{ id: string }>();
const api = useApi(myPluginApiRef);
const navigate = useNavigate();
const [item, setItem] = useState<Item | null>(null);
useEffect(() => {
if (id) {
api.getItem(id).then(setItem);
}
}, [id, api]);
const handleSubmit = async (data: CreateItem) => {
if (id) {
await api.updateItem({ id, data });
} else {
await api.createItem(data);
}
navigate("/items");
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold">
{id ? "Edit Item" : "Create Item"}
</h1>
<ItemForm initialData={item} onSubmit={handleSubmit} />
</div>
);
};
Always type your components and APIs:
interface ItemListProps {
items: Item[];
onItemClick: (id: string) => void;
}
export const ItemList: React.FC<ItemListProps> = ({ items, onItemClick }) => {
// ...
};
// components/ItemCard.tsx
export const ItemCard = ({ item }: { item: Item }) => {
return (
<Card>
<h3>{item.name}</h3>
<p>{item.description}</p>
</Card>
);
};
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!data) return <EmptyState />;
<main>
<header>
<h1>Items</h1>
</header>
<section>
<ItemList items={items} />
</section>
</main>
<Button aria-label="Delete item">
<TrashIcon />
</Button>
<Input
id="name"
aria-describedby="name-error"
aria-invalid={!!errors.name}
/>
Import types from the common package instead of redefining them:
// ✅ Good - Use contract types
import type { Item, CreateItem } from "@checkstack/myplugin-common";
// ❌ Bad - Duplicate type definitions
interface Item {
id: string;
name: string;
// ...
}
import { describe, expect, test } from "bun:test";
import { render, screen } from "@testing-library/react";
import { ItemCard } from "./ItemCard";
describe("ItemCard", () => {
test("renders item name", () => {
const item = { id: "1", name: "Test Item" };
render(<ItemCard item={item} />);
expect(screen.getByText("Test Item")).toBeInTheDocument();
});
});
import { test, expect } from "@playwright/test";
test("create item", async ({ page }) => {
await page.goto("/items");
await page.click("text=Create Item");
await page.fill("#name", "New Item");
await page.click("text=Save");
await expect(page.locator("text=New Item")).toBeVisible();
});
Check that:
-common packagepluginId matches backend router registration nameIf TypeScript complains about contract types:
-common package*Api definition and contract are exported from src/index.tsrm -rf tsconfig.tsbuildinfoCheck that:
routes arrayroute: with RouteDefinition from commonCheck that:
If RPC calls return 404:
Frontend plugins can be loaded and unloaded at runtime without a page refresh. When a frontend plugin is installed or uninstalled on the backend, the platform broadcasts a signal to all connected frontends, triggering automatic UI updates.
sequenceDiagram
participant Admin as Admin UI
participant Backend as Backend
participant Signal as Signal Service
participant Frontend as Frontend
participant Registry as Plugin Registry
Admin->>Backend: Install plugin
Backend->>Backend: Load plugin
Backend->>Signal: Broadcast PLUGIN_INSTALLED
Signal->>Frontend: WebSocket signal
Frontend->>Frontend: loadSinglePlugin()
Frontend->>Registry: register(plugin)
Registry->>Frontend: Re-render UI
Signal Emission: The backend emits PLUGIN_INSTALLED or PLUGIN_DEREGISTERED signals only for frontend plugins (those ending with -frontend)
usePluginLifecycle hook listens for these signals:
// Automatically handled in App.tsx via usePluginLifecycle()
useSignal(PLUGIN_INSTALLED, ({ pluginId }) => {
loadSinglePlugin(pluginId); // Dynamically loads JS/CSS
});
useSignal(PLUGIN_DEREGISTERED, ({ pluginId }) => {
unloadPlugin(pluginId); // Removes from registry
});
pluginRegistry increments its version, triggering React re-renders to pick up new routes and extensions.The pluginRegistry supports dynamic updates:
// Subscribe to registry changes
pluginRegistry.subscribe(() => {
console.log("Registry changed, re-render!");
});
// Check if a plugin is registered
if (pluginRegistry.hasPlugin("my-plugin-frontend")) {
// Plugin is available
}
// Get current version (increments on every change)
const version = pluginRegistry.getVersion();
| Signal | Payload | Description |
|---|---|---|
PLUGIN_INSTALLED |
{ pluginId: string } |
Frontend plugin was installed |
PLUGIN_DEREGISTERED |
{ pluginId: string } |
Frontend plugin was removed |
Note: Only plugins ending with
-frontendtrigger signals. Backend-only plugins are not signaled to the frontend.