Skip to content

A plugin's frontend

The shell loads plugin frontends at runtime through Module Federation. No redeploy, no new program version. Your remote is loaded when somebody opens your route or your page, and unmounted again on sign-out.

If your plugin has no screens of its own, leave frontend out of the manifest. A connector needs no frontend: its settings form comes from the declaration.

Every plugin frontend exposes exactly two modules:

vite.config.ts
federation({
name: 'acme_report',
filename: 'remoteEntry.js',
exposes: {
'./register': './frontend/register.ts',
'./View': './frontend/View.tsx'
},
shared: {
react: { singleton: true, requiredVersion: false },
'react-dom': { singleton: true, requiredVersion: false },
'@prodlog/ui': { singleton: true, requiredVersion: false },
'@prodlog/sdk': { singleton: true, requiredVersion: false }
}
})

react, react-dom, @prodlog/ui and @prodlog/sdk are singletons dictated by the host. A second copy of React throws invalid-hook-call inside the shell — the most common cause of “my plugin loads but shows nothing”.

export const contribution = {
id: 'acme.report',
path: '/acme.report',
view: 'acme.report/View'
};
export default contribution;

This file is read after the remote has loaded. The manifest is what the host validates at install time; keep the two in lockstep.

import { useState, type ReactNode } from 'react';
import type { ShellContext } from '@prodlog/sdk';
import '@prodlog/ui/plugin.css';
export default function View({ ctx }: { ctx: ShellContext }): ReactNode {
return <p>{ctx.locale}</p>;
}

Anything that must be created per mount — a query client, a context provider — belongs inside this component and not at module level. Module Federation caches a remote for the page’s lifetime; module-global state survives a tenant switch in the same browser window.

The only thing the shell hands you:

interface ShellContext {
readonly locale: string; // BCP-47, e.g. 'de' or 'en'
notify(input: NotifyInput): void; // level: 'info' | 'success' | 'warn' | 'error'
readonly store: ScopedStore;
readonly surface: Surface; // 'main' | 'kiosk' | 'capture'
readonly log: Logger;
readonly correlationId: string;
readonly page?: PageInstance; // only on the page-type route
readonly theme?: ThemeControl; // the tenant's colours, for a theme editor
readonly language?: LanguageControl; // switch language where the shell has no switcher
readonly help?: HelpControl; // the help: link an article, explain a label
readonly configurable?: boolean; // false: hide your editors (the native app)
}

The fields with a ? are optional: a shell that does not offer one leaves it out, and your plugin must work without it.

notify is the mediated sink for toasts and ARIA-live messages. Do not build your own banner — the shell draws the same one for every plugin.

store is a plain key-value store bound to your plugin namespace. Values are opaque to the shell; it never reads them.

await ctx.store.set('user', 'acme.report', 'lastFilter', { line: 'A' });
const value = await ctx.store.get('user', 'acme.report', 'lastFilter');

Scopes: tenant (the whole tenant) and user (one person across their sessions). client — one browser tab — is ephemeral and rejected by the persistent store; that state belongs in the tab, not in the database.

surface says where you are being drawn: main is the normal shell, kiosk the chrome-less presentation mode, capture a touch-first capture surface. Do not draw a toolbar in kiosk.

correlationId is minted by the shell once per mount. Forward it on your own backend calls in the x-correlation-id header — then the line your backend route writes hangs on the same id, and one plugin session reads as one story. The granularity is the mount, not the click: it survives a language or surface change.

help connects your screens to the help panel. You cannot import the shell’s help components — your frontend is a bundle of its own — so the shell hands you this instead:

const article = ctx.help?.topic('boards'); // null: no such article
const forLabel = ctx.help?.forKey('widget.none'); // the article that explains your label
if (article) ctx.help?.open(article.id); // shows it in the help panel

Each answer carries id, title, lead (the first paragraph, plain text) and href (the article in the Help Center). @prodlog/ui draws them the way the shell does: HelpTopicLink for a line under an error or an empty state, HelpTopicLabel for a label that opens a box when pointed at.

forKey looks the key up in your namespace: the shell puts your plugin id in front, so board asking for widget.none finds the article whose covers: lists board:widget.none. You cannot claim a label of the shell or of another plugin.

Every answer is null while the help is still loading, in an installation built without a Help Center, and on surfaces without a help panel. Draw nothing then — never a link that leads nowhere. The context is rebuilt when the help arrives, so a link appears by itself.

A plugin ships no global styles. Import @prodlog/ui/plugin.css, not @prodlog/ui/styles.css.

import '@prodlog/ui/plugin.css';

Not included, and deliberately reserved for the shell:

  • Preflight — a global reset applies to the whole document. A remote that brings one restyles elements that do not belong to it.
  • The fonts — otherwise every remote loads the same faces again.
  • The token block — the tokens sit on :root in the shell’s document; an hsl(var(--brand)) in your plugin resolves against them by inheritance.

A remote’s finished stylesheet is wrapped at build time into a weaker cascade layer. Unlayered rules always win over layered ones in CSS, regardless of order — and the shell stays unlayered. So no remote can override the shell, however many load and in whatever order. Your own elements you style exactly as before.

This is not a request; the shell enforces it. The reason was measurable: an unconditional .hidden{display:none} from a plugin beat the shell’s own rule, and the wordmark disappeared as soon as a plugin page had been open once.

A plugin used to contribute one menu entry, identical for every tenant. A page type turns that around: your plugin declares a kind of page, and the tenant creates as many named instances of it as it wants and hangs them in its own tree.

{
"category": "pageType",
"id": "acme.report",
"label": "Report",
"meta": {
"label": "Report",
"defaultIcon": "clipboard-list",
"create": { "path": "/reports", "nameField": "name", "refField": "id" }
}
}

To create one, the shell calls POST /api/plugins/<id><create.path>, sends the name the tenant typed under create.nameField, and reads the new instance’s reference from the top-level create.refField member of your answer. POST only, with no method knob: creating a thing is a POST.

To render one, you get the instance on the context:

export default function View({ ctx }: { ctx: ShellContext }): ReactNode {
if (!ctx.page) {
// reached through your own `route` contribution - there is no instance here
return <Overview />;
}
return <Report type={ctx.page.type} reference={ctx.page.ref} />;
}

ctx.page.ref is exactly what your own create call handed back. The shell never interprets it — it is your identifier for your own thing.

The direction is always navigation → page type, never back. Your plugin is never told that a menu exists, is never handed a navigation node id, and has nothing to call back into. A menu is the tenant’s own rights surface.

An instance’s route is /p/<pageType>/<pageRef>; the constant is PAGE_ROUTE_PREFIX, and pageRoutePath(type, ref) builds it.

  • Nothing loads. Check that frontend.entry is in the manifest and that the file sits under dist/ in the archive. With either missing there is nothing for the shell to load, and it does not even try.
  • invalid-hook-call. A singleton is missing from shared, usually react-dom.
  • The shell looks broken after your page has been opened. You are importing styles.css instead of plugin.css.
  • After an update the page shows the old state. The remote name is built from id and version, deliberately. Check that the manifest version actually went up.