Appearance
Add a custom block (a "widget" you build yourself)
What you're doing
The one UI unit on this platform is a Block — every page is a tree of blocks (core.grid, core.text-input, core.button, ...), rendered by a generic engine that knows nothing about any specific block's internals. This guide adds a NEW block type of your own — real React, real useState/hooks, real data — that plugs into that same tree.
This is narrower than a full Code Plugin with custom Java: you don't need a Plugin subclass, a Java extension, or any backend code at all. A custom block can be pure frontend, reading data through the platform's existing generic query engine — reach for this guide alone whenever the need is "a bespoke piece of UI with real component state," and only add the Java half if the data or logic it needs genuinely can't come from an existing endpoint (see that guide's "When to reach for this" table).
How it's different from a page or a full Code Plugin
| An ordinary page | A custom block (this guide) | A full Code Plugin | |
|---|---|---|---|
| Built from | Existing block types, composed in JSON | Your OWN new block type | Your own block type + your own Java |
| Where it can be used | That one page | Any page, in any app, once registered | Any page, in any app |
| Needs React/hooks? | No | Yes | Yes |
| Needs Java? | No | No | Only if the data/logic needs it |
The two-part registration seam
A custom block is two things, registered together:
- The block's data/logic contract — a
BlockDefinition(its property surface: names, types, designer config) plusBlockLogic(arender()function), admitted via@erp/block-engine'sregistry.registerExternal({ definition, logic })— the IDENTICAL validation path every core block (core.grid,core.button, ...) goes through. Nothing about this seam is second-class. - The actual React component — registered via
@erp/block-adapter-mui'sregisterCustomRenderer(pluginKind, Component). Yourrender()function emits the reserved"custom"render-node kind carrying apluginKindstring; the adapter looks that string up in this registry and mounts your real component. No match → a neutral placeholder, never a crash.
Both calls are usually wrapped in one install*(registry) function, run once when your plugin's frontend bundle loads.
The complete example
This platform's own real, already-shipped example — frontend/packages/erp-code-plugin-demo/src/riskScoreBlock.ts + RiskScoreCard.tsx + register.ts — is genuinely runnable, not a snippet. Read alongside this guide:
riskScoreBlock.ts — the BlockDefinition:
ts
import type { BlockDefinition, BlockIO, BlockLogic, RenderNode } from "@erp/block-engine";
import { node } from "@erp/block-engine";
export const PLUGIN_KIND = "erp-code-plugin-demo.risk-score-card";
export const riskScoreCardDefinition: BlockDefinition = {
contractVersion: 1,
publisher: "erp-code-plugin-demo",
permissions: { visible: true, enabled: true, masked: true },
type: "erp-code-plugin-demo.risk-score-card",
version: "1.0.0",
properties: [
{
name: "entityName",
type: "string",
required: true,
sources: ["static"],
designer: { group: "data", editor: "text", labelKey: "erpCodePluginDemo.riskScoreCard.property.entityName" },
},
{
name: "titleKey",
type: "string",
sources: ["static"],
default: "erpCodePluginDemo.riskScoreCard.title",
designer: { group: "content", editor: "text", labelKey: "erpCodePluginDemo.riskScoreCard.property.titleKey" },
},
],
a11y: { role: "region", labelFrom: "i18n:erpCodePluginDemo.riskScoreCard.title" },
designer: {
displayNameKey: "erpCodePluginDemo.riskScoreCard.displayName",
descriptionKey: "erpCodePluginDemo.riskScoreCard.description",
icon: "insights",
category: "display",
allowedTargets: ["page", "dashboard"],
propertyGroups: [
{ id: "content", titleKey: "core.designer.group.content" },
{ id: "data", titleKey: "core.designer.group.data" },
],
preview: { kind: "text" },
},
events: [],
};
export const riskScoreCardLogic: BlockLogic = {
render(io: BlockIO): RenderNode {
return node("custom", io.instanceId, {
props: { pluginKind: PLUGIN_KIND, entityName: io.props["entityName"], titleKey: io.props["titleKey"] },
});
},
};RiskScoreCard.tsx — the real component (trimmed; see the file for the full version):
tsx
import { useMemo, useState } from "react";
import { Card, CardContent, Chip, Stack, Typography } from "@mui/material";
import type { RenderNode } from "@erp/block-engine";
import { useERPQuery } from "@erp/data";
function scoreFor(rows: Array<Record<string, unknown>>) {
const activeCount = rows.filter((r) => r["active"] === true).length;
const score = rows.length === 0 ? 0 : Math.round((activeCount / rows.length) * 100);
return { score, band: score >= 70 ? "low" : score >= 40 ? "medium" : "high" } as const;
}
export function RiskScoreCard({ n }: { n: RenderNode }) {
const entityName = n.props["entityName"] as string;
const [expanded, setExpanded] = useState(false); // real component state
const { rows, loading, error } = useERPQuery({ entity: entityName, pageSize: 25 }); // real data
const { score, band } = useMemo(() => scoreFor(rows), [rows]);
return (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1">{String(n.props["titleKey"])}</Typography>
{!loading && !error && (
<Stack direction="row" spacing={1}>
<Typography variant="h4">{score}</Typography>
<Chip label={band} onClick={() => setExpanded((v) => !v)} />
</Stack>
)}
</CardContent>
</Card>
);
}register.ts — both halves, together:
ts
import type { BlockRegistry } from "@erp/block-engine";
import { registerCustomRenderer } from "@erp/block-adapter-mui";
import { riskScoreCardDefinition, riskScoreCardLogic, PLUGIN_KIND } from "./riskScoreBlock";
import { RiskScoreCard } from "./RiskScoreCard";
export function installRiskScoreCardPlugin(registry: BlockRegistry) {
const result = registry.registerExternal({ definition: riskScoreCardDefinition, logic: riskScoreCardLogic });
registerCustomRenderer(PLUGIN_KIND, RiskScoreCard);
return result;
}Build and verify it yourself
This is a real, already-shipped workspace package — run its own checks directly, no scaffolding needed:
bash
cd frontend/packages/erp-code-plugin-demo
pnpm typecheck
pnpm test # real jsdom mount of this exact block, see riskScoreCard.e2e.test.tsx
pnpm build # tsup — produces dist/index.jsProperty surface → the designer's config panel
Each entry in BlockDefinition.properties becomes one field in the page designer's property panel for an instance of your block, once it's dropped onto a canvas:
name/type/required— the property's identity and validation.designer.group— which tab/section of the panel it appears under (riskScoreCardDefinitiondeclares two:content,data).designer.editor— which input control renders it (texthere; other block types useselect,checkbox,binding-picker, etc.).designer.labelKey— the i18n key for the field's label.sources: ["static"]— v0 property values are static only, same static-vs- binding distinction the Code Plugin compile-to-JSON path documents.
Why this matters: cross-app reusability
Once installRiskScoreCardPlugin runs against a host's BlockRegistry, the block type erp-code-plugin-demo.risk-score-card is registered platform-wide for that session — it shows up in the page designer's block palette and can be dragged onto any page, in any app, not only pages belonging to the plugin that registered it. That's the actual point of building a custom block instead of a full custom page: you write the component once, and every other page author on the tenant gets to use it declaratively, the same way they use core.grid or core.button.
Getting it onto a live tenant
A registered block only exists in whatever process called registerExternal — shipping it to a real tenant is the same frontendBundle + erp plugin publish-frontend mechanism the Code Plugin guide describes. If your block needs no Java at all (as RiskScoreCard doesn't — it reads through the generic query engine), you skip that guide's Java sections entirely and package a pure-JSON .spk (mainClass: null) whose frontendBundle points at your component's compiled browser bundle.
What this guide does NOT cover
- A block whose data comes from your OWN Java code (a plugin-owned REST route, not the generic query engine) — see Build a plugin with custom React + Java code.
- Data binding (
{source: "binding"}) — v0 property values are static only; see that guide's own "not supported" boundary inai/patterns/code-plugin-sdk.md. - Wiring your block into Studio's design-time preview surfaces yourself — already real and automatic once
frontendBundleis set (the dynamic loader wires designer previews and the live page host the same way).