Frontend plugin development¶
This guide explains how to implement and register a new frontend plugin in the application. It is the frontend counterpart to the backend plugin development guide.
The plugin system is designed to be:
- Modular
- Lazy-loaded
- Route-based
All paths in this guide are relative to the
frontend/directory.
Plugin Directory¶
All plugins live under:
plugins/
1. Defining a new plugin¶
Create a new folder using the plugin’s kebab-case name:
plugins/<plugin-name>/
Example:
plugins/example-plugin/
2. Create the Plugin UI Component¶
Inside the new plugin directory, create a React component that represents the plugin’s UI.
File
plugins/<plugin-name>/<PluginName>.tsx
Example:
"use client";
export default function ExamplePlugin() {
return (
<div className="p-6">
<h1 className="text-xl font-semibold">Example Plugin</h1>
{/* Plugin UI goes here */}
</div>
);
}
Notes
- Must be a client component
- Can use plugin context via
usePlugins()orcreatePluginContext - This component is lazy-loaded
3. Define the Plugin (index.ts)¶
Each plugin must export a PluginDefinition from its own index.ts.
Example Plugin Structure
plugins/
└─ chat/
├─ ChatInterface.tsx
└─ index.ts
File
plugins/<plugin-name>/index.ts
Example
import { PluginDefinition } from "@/lib/plugins";
export const examplePlugin: PluginDefinition = {
name: "example-plugin",
loadComponent: () => import("./ExamplePlugin"),
};
Note
A PluginDefinition holds only what the frontend alone can own:
name– unique plugin identifier (must match folder and route, and the backend plugin's declared name (see "Plugin Names" in the backend plugin development guide), so the UI resolves to the right backend plugin)loadComponent– lazy-loaded UI componentloadSettingsComponent(optional) – custom settings modal
The display name, description, icon, and sidebar entry are declared by the
backend plugin through the frontend metadata hooks (DISPLAY_INFO,
SIDEBAR_ENTRIES, FRONTEND_APPS, see the backend guide) and arrive on the
user-plugins API response (display, sidebar, has_frontend on
UserPluginState). The frontend renders what the backend declares; there is no
frontend-side copy to keep in sync.
UserPluginState is the generated UserPluginResponse schema
(Schema<"UserPluginResponse">), not a hand-written mirror of it, so the shape
cannot drift from the API. One consequence for settings UIs: config values are
typed unknown, because the backend stores config as JSONB and a field declared
as a string can still arrive as an array or a number. Narrow at the read site
with the helpers in @/lib/plugins/config — configString(config, key) for a
single text field (non-strings fall back to undefined rather than coercing),
and stringConfigOf(config) for an editor that renders every field as an input.
Icons cross the wire as lucide icon names. The
resolver in @/lib/plugins/icons maps names to components, and only the lucide
names already in that map resolve (icons are mapped explicitly to keep the
bundle tree-shakeable). A backend plugin declaring a new lucide name must also
add it to the map in frontend/lib/plugins/icons.ts, or the UI silently falls
back to an icon-less rendering (the resolver logs a console.warn outside
production to surface the gap). A plugin that ships a custom (non-lucide) icon
registers it under its declared name:
import { registerPluginIcon } from "@/lib/plugins/icons";
import ExampleBrandIcon from "./ExampleBrandIcon";
registerPluginIcon("example-brand", ExampleBrandIcon);
For more details, see the PluginDefinition type in
frontend/lib/plugins/types.ts.
4. Publicly Export the Plugin¶
Expose the plugin from the plugins barrel file.
File
plugins/index.ts
export * from "./chat"; // already existing
// your plugin export goes here
export * from "./example-plugin";
This makes the plugin available for registration.
5. Register the Plugin¶
Register the plugin with the plugin registry.
File
@/lib/plugins/index.ts
import { registerPlugin } from "./registry";
import { chatPlugin, examplePlugin } from "@/plugins";
registerPlugin(chatPlugin); // already existing
// your plugin registration goes here
registerPlugin(examplePlugin);
export * from "./registry";
export * from "./types";
export * from "./usePlugins";
export * from "./metadata";
export * from "./config";
export * from "./icons";
⚠️ If a plugin is not registered here, it will not load, not render, and not appear in the sidebar.
6. Regenerate the Plugin Route List¶
Each plugin is rendered under:
dashboard/<pluginName>
The static routes are generated from the backend declarations, not
hand-maintained: generateStaticParams in
app/dashboard/[pluginName]/page.tsx reads FRONTEND_PLUGIN_NAMES from
lib/plugins/generated.ts, which lists every backend plugin that registered
the FRONTEND_APPS hook. After declaring (or removing) a frontend app on the
backend plugin, regenerate the list:
make frontend.build.plugins
Notes
- Do not edit
lib/plugins/generated.tsby hand. The dump script is its sole formatter, so the file is listed in.oxfmtrc.jsonignorePatterns(aslib/api/generated.tsalready is) — otherwisemake formatwould rewrite it and break the byte-exact backend gate - To inspect the dump without touching the committed file, run the script with
no arguments and it prints to stdout:
uv run python scripts/dump_frontend_plugins.py. The make target passes-o frontend/lib/plugins/generated.ts, which writes atomically, so a failed dump leaves the committed file intact - Tests on both tiers fail when the committed list drifts from the backend
declarations (
tests/core/test_dump_frontend_plugins.py) or when the frontend registry does not match it (lib/tests/plugin-registry-drift.test.ts) PluginPageClienthandles loading the correct plugin dynamically
Translations (Optional)¶
A plugin owns its message catalogs, mirroring the backend rule: the core
messages/*.json files carry no plugin namespace, and a plugin with
user-facing strings ships its own catalogs under its directory, scoped to a
single top-level namespace equal to the plugin name.
1. Create the catalogs
plugins/example-plugin/messages/en.json
plugins/example-plugin/messages/es.json
plugins/example-plugin/messages/fr.json
{
"example-plugin": {
"greeting": "Hello from the example plugin"
}
}
Every locale in lib/i18n/config.ts needs a file, all carrying the same keys.
2. Declare the loader on the plugin definition
import type { Locale } from "@/lib/i18n/config";
const messageCatalogs: Record<Locale, () => Promise<{ default: Record<string, unknown> }>> = {
en: () => import("./messages/en.json"),
es: () => import("./messages/es.json"),
fr: () => import("./messages/fr.json"),
};
export const examplePlugin: PluginDefinition = {
name: "example-plugin",
loadComponent: () => import("./ExamplePlugin"),
loadMessages: (locale) => messageCatalogs[locale]().then((catalog) => catalog.default),
};
loadMessages in lib/i18n/messages.ts merges every registered plugin's
catalog into the core one at load time; a catalog that fails to load falls
back to the plugin's English one.
3. Bind the types
Intersect the plugin's English catalog into the PluginMessages type in
lib/i18n/messages.ts (type-only, so the plugin stays a self-contained
runtime unit):
import type examplePluginMessages from "@/plugins/example-plugin/messages/en.json";
type PluginMessages = typeof chatMessages & typeof examplePluginMessages;
4. Use the namespace in components
const t = useTranslations("example-plugin");
<p>{t("greeting")}</p>
Plugin components use only their own namespace, never a core one. The catalog
drift guard (make test.frontend.i18n) picks the new messages/ directory up
automatically and checks it against the plugin's own sources, and
frontend/tests/lib/i18n/messages.test.ts enforces the namespace containment.
In component tests, pass the plugin catalog to the intl helper:
renderWithIntl(<ExamplePlugin />, exampleEn). See the
translations guide for the full picture.
Plugin Loading Flow (Summary)¶
plugins/<plugin>
↓
PluginDefinition
↓
registerPlugin()
↓
Plugin registry
↓
Sidebar + routing
↓
Dynamic import
↓
Rendered plugin UI
Troubleshooting¶
Plugin not showing?
- Ensure it’s exported from
plugins/index.ts - Ensure it’s registered in
lib/plugins/index.ts
Plugin page blank?
- Check
loadComponentpath - Ensure component is a default export
- Ensure route param matches plugin name