Appearance
Build a full-code frontend for a plugin (any framework)
What you're doing
Every module on the platform is a plugin, and each plugin chooses its own mode: no-code (pages and forms built in Studio), low-code (Studio plus rules, expressions and small pieces of code), or full-code (you write the screens yourself). Modes are per plugin, so one tenant can have an HR module built in Studio, a finance module built with Studio and a few code blocks, and a logistics module that is entirely code, side by side, sharing the same entities, permissions, workflows and integrations.
For full-code screens you use the UI framework you like: React, Vue, Angular, Svelte, Lit or plain JavaScript. The platform does not care which, because it talks to your screen through a web standard, the Web Component (a custom element): it sets properties on the element and listens for the events the element raises. Every mainstream framework can produce one.
your screen (any framework) -> a Web Component -> registered as a block -> placed on a platform pageThe screen appears inside the platform's own page frame. The platform keeps the frame: sign-in, the top bar and menu, tenant and application switching, the theme, notifications, the URL structure and the permission check on each page and menu entry. Your code owns everything inside the content area, which can be the whole page. (A completely independent app with its own shell, domain and sign-in is a different thing: build it in any framework and call the platform's REST APIs; it is not a plugin.)
The contract
Properties in. Each property you declare is set on the element as a JavaScript property, and set again whenever it changes (a page author can bind it to data). Two extra properties are always given: text (when the block has inert text) and erpContext, an object with baseUrl, tenantId, actor and sessionToken, which is what your screen uses to call the platform's APIs as the signed-in person.
Events out. To raise a declared event, the element dispatches a DOM CustomEvent named exactly like the event, with the payload in detail. Vue's emit and Angular Elements' outputs already do this. The platform passes it to the block's event system (the platform's event bus, which the page's event wiring uses), so the page does not need to know what framework produced it. If your framework cannot name events after your declared events, dispatch new CustomEvent("erp-event", { detail: { name: "saved", payload } }).
Registration. Your bundle's default export receives the page's registry and a host. Define the element once, then describe it:
js
export default function register(registry, host) {
customElements.define("acme-shipment-board", ShipmentBoardElement);
host.registerCustomElementBlock({
type: "acme.shipment-board", // the block type, dot-namespaced
tagName: "acme-shipment-board", // must contain a hyphen
displayName: "Shipment board",
properties: [{ name: "warehouse", label: "Warehouse" }, { name: "pageSize", type: "number", default: 20 }],
events: ["selected"], // plain names: letters, digits, underscore
});
}The bundle imports nothing from the platform. host.registerCustomElementBlock builds a normal, validated block, so it shows up in the Studio palette and can be placed, bound and permissioned like any other. It throws a plain message for a spec that is not valid.
Start from a working template
Do not write the wiring by hand. Generate a project that builds, passes its own test, and registers one block:
bash
platform-cli frontend-plugin:scaffold --id acme-logistics --framework vue --out ./acme-logistics-frontend # or react, or vanilla
cd acme-logistics-frontend && npm install && npm test && npm run build:browserThe same generator is the MCP tool erp_frontend_plugin_scaffold, so an AI coding tool can create the project for you. Each framework's output was installed, type-checked, tested and built here (Vue 3, React 18 and plain TypeScript). Angular has no generated starter; use the Angular Elements snippet below.
Vue
A complete, tested example is in frontend/packages/erp-fullcode-vue-demo (a Vue 3 employee directory, about 60 lines):
ts
import { defineCustomElement, h, ref, watch } from "vue";
export const ShipmentBoard = defineCustomElement({
props: { warehouse: String, pageSize: { type: Number, default: 20 }, erpContext: Object },
emits: ["selected"],
setup(props, { emit }) {
const rows = ref([]);
watch(() => [props.erpContext, props.warehouse], async () => {
const c = props.erpContext; if (!c) return;
const r = await fetch(`${c.baseUrl}/api/v1/shipments?warehouse=${props.warehouse}`, { headers: { "X-Tenant-Id": String(c.tenantId), "X-Session-Token": c.sessionToken } });
rows.value = await r.json();
}, { immediate: true });
return () => h("ul", rows.value.map((r) => h("li", { onClick: () => emit("selected", { id: r.id }) }, r.reference)));
},
});
// customElements.define("acme-shipment-board", ShipmentBoard) (in the registration export)React
React renders into an element you own. No extra library is needed:
tsx
import { createRoot, Root } from "react-dom/client";
class ShipmentBoardElement extends HTMLElement {
private root?: Root;
private props: Record<string, unknown> = {};
connectedCallback() { this.root = createRoot(this); this.render(); }
disconnectedCallback() { this.root?.unmount(); }
set warehouse(v: string) { this.props.warehouse = v; this.render(); }
set erpContext(v: unknown) { this.props.erpContext = v; this.render(); }
private render() {
this.root?.render(<ShipmentBoard {...this.props} onSelected={(d) => this.dispatchEvent(new CustomEvent("selected", { detail: d }))} />);
}
}Angular
Angular Elements turns a component into a custom element; inputs become properties and outputs become events with the same name:
ts
import { createCustomElement } from "@angular/elements";
import { createApplication } from "@angular/platform-browser";
const app = await createApplication({ providers: [] });
customElements.define("acme-shipment-board", createCustomElement(ShipmentBoardComponent, { injector: app.injector }));Plain JavaScript
js
class ShipmentBoardElement extends HTMLElement {
set warehouse(v) { this._warehouse = v; this.paint(); }
set erpContext(v) { this._ctx = v; this.paint(); }
paint() { this.innerHTML = `<h2>${this._warehouse ?? ""}</h2>`; }
}Build it to one file and publish it
Bundle everything, your framework included, into a single ES module (the demo's tsup.browser.config.ts shows the settings), then publish it with the plugin:
bash
pnpm build:browser # dist/browser.js
erp plugin publish-frontend backend/modules/acme-logisticsThe platform stores the file, records it on the plugin and loads it on every page of a tenant that has the plugin installed. A plugin without a frontend bundle costs nothing. If your bundle throws while registering, that plugin's screens are skipped and every other plugin and page still loads.
Put it on a page
In Studio, open the page and add your block from the palette (it appears under the display name you gave), set its properties (each can be a fixed value or bound to page data), and give the page a menu entry and a permission like any other page. For a whole-module screen, make the block fill the page. For a module with several screens, register one block per screen and give each its own page and menu entry. A single-page app with its own internal routing can also sit behind one menu entry.
Test it
The example's test (src/vueBlock.integration.test.ts) is the pattern to copy. It runs your bundle through the platform's real loader, block runtime and web renderer in a test browser, and checks that the screen renders, that it loads data with the signed-in context, and that an event raised inside your framework reaches the platform's event bus. A test of your own element, without the platform, is just a test of a Web Component.
Logs and tracing
- In the browser: the platform logs a problem with your element under
[custom-element-host](a handler that throws, for example) and a bundle that fails to load under[plugin-frontend-loader]; both never break the page. If your element is still undefined after a few seconds, the page shows a small note naming the missing tag, which usually means the bundle did not load or did not callcustomElements.define. Use your framework's own devtools as usual. - Calling your backend: send
erpContextheaders plus theX-Correlation-Idof your own choosing if you want to follow one action across your screen and your service; the platform's own requests carry theirs. A plugin backend behind the platform (/api/v1/plugins/<id>/...) receives the platform's correlation id for every request, which is the same id the platform shows in its trace and logs.
Security
Your bundle runs in the platform's page with the signed-in person's session (erpContext.sessionToken), the same trust as any installed plugin: install only plugins you trust. Every call your screen makes to the platform is checked for the same permissions as the person; the platform never gives a plugin more than the person has.
What is and isn't tested
React, Vue and plain JavaScript follow the same contract, and the Vue example is tested end to end. The React and Angular snippets above are the standard way of producing a custom element but were not built and run here.
