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.
Two module ids, and no more
Section titled “Two module ids, and no more”Every plugin frontend exposes exactly two modules:
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”.
./register — the runtime contribution
Section titled “./register — the runtime contribution”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.
./View — what the shell mounts
Section titled “./View — what the shell mounts”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 ShellContext
Section titled “The ShellContext”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 articleconst forLabel = ctx.help?.forKey('widget.none'); // the article that explains your labelif (article) ctx.help?.open(article.id); // shows it in the help panelEach 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.
Styles: what is yours and what is not
Section titled “Styles: what is yours and what is not”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
:rootin the shell’s document; anhsl(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.
Page types instead of one menu entry
Section titled “Page types instead of one menu entry”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.
When something goes wrong
Section titled “When something goes wrong”- Nothing loads. Check that
frontend.entryis in the manifest and that the file sits underdist/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 fromshared, usuallyreact-dom.- The shell looks broken after your page has been opened. You are importing
styles.cssinstead ofplugin.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.