Skip to content

Plugin System

Plugins are an admin-panel concept. They run in the browser, inside the React admin, and are registered where you build it. Nothing in this page reaches the backend: a plugin cannot add a route, a callback or a cron. For those, see Custom Functions, Entity Callbacks and Cron Jobs.

Plugins are the primary extension mechanism in the panel. They can:

  • Wrap the entire app with a provider (context, state management)
  • Add home page actions and widgets
  • Inject collection view components (toolbar, column builders)
  • Add form components (field builders, additional panels)
  • Inject or modify collections dynamically
interface RebasePlugin {
key: string; // Unique identifier
loading?: boolean; // Hold admin content until the plugin is ready
// UI contributions — a flat array, each entry naming its slot.
// This replaced the old per-area objects (homePage, collectionView, form).
slots?: SlotContribution[];
// HOC providers. `scope: "root"` wraps the whole admin below
// RebaseContext; `scope: "form"` wraps each entity form / edit view.
providers?: PluginProvider[];
// Behavioural (non-UI) hooks: collection modification and injection,
// column reordering, navigation entries.
hooks?: PluginHooks;
// Custom field rendering (e.g. data enhancement).
fieldBuilder?: FieldBuilderConfig;
// Views added to the navigation automatically.
views?: AppView[];
// onMount, onAuthStateChange, onUnmount — see below.
lifecycle?: PluginLifecycle;
}

Every one of these is optional except key. The full slot-name list lives on the Slots page.

  • onMount(context) runs once, when auth first becomes ready: a user is signed in, or sign-in was skipped.
  • onAuthStateChange(user) runs on every later change of user: the new user on a sign-in, null on sign-out. The plugin stays mounted across both.
  • onUnmount() runs when <Rebase> unmounts.

Since 0.25 lifecycle.onCollectionChange is deprecated. Nothing ever called it, on 0.24 or before. A plugin that sets it still compiles, and <Rebase> warns once per plugin. To follow a collection’s rows, subscribe from onMount and unsubscribe in onUnmount:

import type { RebasePlugin } from "@rebasepro/cms-types";
let stopListening: (() => void) | undefined;
const openOrdersPlugin: RebasePlugin = {
key: "open_orders",
lifecycle: {
onMount: (context) => {
stopListening = context.data.collection("orders").listen(
{ where: { status: ["==", "open"] } },
({ data }) => console.log(`${data.length} open orders`)
);
},
onUnmount: () => stopListening?.()
}
};

Plugins go on <Rebase>, next to the client. Everything below it — the navigation, the collection views, the forms — reads them from there:

import { Rebase, useRebaseAuthController } from "@rebasepro/app";
import { RebaseCMS, RebaseShell } from "@rebasepro/cms";
import { useDataEnhancementPlugin } from "@rebasepro/plugin-ai";
export function App() {
const authController = useRebaseAuthController({ client });
const dataEnhancementPlugin = useDataEnhancementPlugin();
return (
<Rebase
client={client}
authController={authController}
plugins={[dataEnhancementPlugin]}
>
<RebaseCMS collections={collections}/>
<RebaseShell title="My App"/>
</Rebase>
);
}

Plugins are usually built by a hook, so the array is rebuilt on every render; that is fine, and it is why plugins is a prop rather than something you memoize by hand. Two plugins with the same key are a mistake — <Rebase> logs the duplicates rather than silently dropping one.

For a single contribution you do not need a plugin at all: <Rebase slots> takes the same SlotContribution entries directly.

Only if you have replaced <RebaseShell> with the layers underneath it does the plugin list have to be threaded by hand, into the navigation controller:

const navigationStateController = useBuildNavigationStateController({
plugins,
collections: () => collections,
// These four are required — the controller resolves navigation against them.
authController,
data,
collectionRegistryController,
urlController
});

<RebaseNavigation> does exactly this call for you, reading plugins off the customization controller <Rebase> provides. See Advanced: manual layout.

Here’s a minimal plugin that adds a toolbar action to every collection:

import type { RebasePlugin } from "@rebasepro/cms-types";
function useMyPlugin(): RebasePlugin {
return {
key: "my_plugin",
// `slots` is a flat array of contributions, each naming its slot.
// See the Slots page for the full list of slot names.
slots: [
{ slot: "collection.actions", Component: MyToolbarAction }
],
// `fieldBuilder` is top-level and takes a `wrap` function that returns
// a *component* (or null to leave the field as it is) — it is not
// a render function and no longer lives under `form`.
fieldBuilder: {
wrap: ({ property }) =>
property.propertyConfig === "my_custom_field" ? MyCustomField : null
}
};
}

Since 0.25 On 0.24 each plugin’s wrap received the built-in field, and only the last wrapper returned was rendered.

Wrappers from several plugins compose, in the order of the plugins array. Each plugin’s wrap receives, as params.Field, the field as the plugins before it left it, so the first plugin’s wrapper is innermost and the last one’s outermost. A plugin whose wrap returns null, or whose enabled guard returns false for the field, passes it on unchanged. A wrapper that renders Field therefore keeps every wrapper before it:

import type { RebasePlugin } from "@rebasepro/cms-types";
import { Typography } from "@rebasepro/ui";
const descriptionPlugin: RebasePlugin = {
key: "description_below",
fieldBuilder: {
// `Field` is the built-in field, or the earlier plugins' wrapper of it.
wrap: ({ Field, property }) => property.description
? (props) => (
<>
<Field {...props}/>
<Typography variant="caption" color="secondary">
{property.description}
</Typography>
</>
)
: null
}
};

AI-powered field autocompletion:

import { useDataEnhancementPlugin } from "@rebasepro/plugin-ai";
const enhancementPlugin = useDataEnhancementPlugin();

It adds an Autofill action to every form, and an Autocomplete slash command to every markdown field, which continues the text from the cursor. Both appear only where the plugin is enabled: the service reports itself available and getConfigForPath (when you pass one) allows the collection. A field whose customProps.editorProps.aiController names a controller of its own uses that one. Since 0.25 Another plugin can give markdown fields a controller the same way, with EditorAIControllerProvider from @rebasepro/cms in a form-scoped provider.

Data enhancement

Plugins can dynamically add new collections:

hooks: {
// Receives the resolved collections and returns the full list to use.
injectCollections: (collections) => [...collections, auditLogCollection]
}

Plugins can modify existing collections:

hooks: {
// Receives one collection, returns the modified one.
modifyCollection: (collection) => ({
...collection,
properties: {
...collection.properties,
last_modified_by: {
type: "string",
name: "Modified By",
admin: { readOnly: true }
}
}
})
}

modifyCollection is synchronous, and the admin applies it when it resolves its collections, not each time the plugin changes. When the change needs data you have to fetch, fetch it before rendering <Rebase> and build the plugin from what came back:

import { useEffect, useMemo, useState } from "react";
import { Rebase, useRebaseAuthController } from "@rebasepro/app";
import { RebaseCMS, RebaseShell } from "@rebasepro/cms";
import type { RebasePlugin } from "@rebasepro/cms-types";
import { CircularProgressCenter } from "@rebasepro/ui";
export function App() {
const authController = useRebaseAuthController({ client });
const [labels, setLabels] = useState<Record<string, string>>();
useEffect(() => {
fetch("/collection-labels.json")
.then((response) => response.json())
.then(setLabels);
}, []);
const labelsPlugin = useMemo<RebasePlugin | undefined>(() => labels && {
key: "collection_labels",
hooks: {
modifyCollection: (collection) => ({
...collection,
name: labels[collection.slug] ?? collection.name
})
}
}, [labels]);
if (!labelsPlugin) return <CircularProgressCenter/>;
return (
<Rebase client={client} authController={authController} plugins={[labelsPlugin]}>
<RebaseCMS collections={collections}/>
<RebaseShell title="My App"/>
</Rebase>
);
}

Since 0.25 hooks.modifyCollectionAsync is deprecated. Nothing ever called it, on 0.24 or before. A plugin that sets it still compiles, and <Rebase> warns once per plugin. Use modifyCollection as above.