Skip to content

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() or createPluginContext
  • 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 component
  • loadSettingsComponent (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/configconfigString(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.ts by hand. The dump script is its sole formatter, so the file is listed in .oxfmtrc.json ignorePatterns (as lib/api/generated.ts already is) — otherwise make format would 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)
  • PluginPageClient handles 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 loadComponent path
  • Ensure component is a default export
  • Ensure route param matches plugin name