Skip to content

Build a plugin with custom React + Java code

What you're doing

Every other guide in these docs builds a plugin out of JSON: entities, pages, data services, workflows. That covers the large majority of real screens. This guide is for the remaining case — a screen that needs real component state, hooks, or business logic the block engine can't express, backed by real server-side logic the Entity / Query / Rule / Workflow / Job engines can't express either.

You'll use two escape hatches together, both real and already shipped:

  • The custom-component escape hatch (frontend) — a genuinely arbitrary React component, mounted through @erp/block-engine's registerExternal + @erp/block-adapter-mui's registerCustomRenderer, reading data through @erp/data's hooks or plain fetch.
  • A Java Plugin extension (backend) — a real class implementing one of engine-plugin-api's extension-point interfaces (RestContribution, DashboardWidgetContribution, and others — see erp platform catalog), loaded in-process into the shared ERP via PF4J.

When to reach for this (and when not to)

Try these first, in order — see create-a-plugin.md's own "Java: only if you need it" section:

  1. JSON composition — entities, pages, data services/views, workflows, rules. No code at all. Covers most screens.
  2. A scheduled job config row — no Java, see the job guides.
  3. The compile-to-JSON Code Plugin path@erp/ui + definePlugin(), for declarative composition of existing blocks with no runtime hooks/state.
  4. The custom-component escape hatch alone (frontend only) — real React, reading data through the generic query engine (useERPQuery). No Java — see Add a custom block if this is all you need.
  5. This guide — real React AND real Java, when the data or logic your component needs isn't something the generic engines can compute at all (a bespoke server-side calculation, a call to a library or resource only your Java code should touch).

Level 4 (this guide) vs Level 5 (a tenant extension service)

Both let you write real, unrestricted code. They differ in where it runs and what it's allowed to touch:

Level 4 — a Java plugin (this guide)Level 5 — a tenant extension service
Where your code runsIn-process, loaded via PF4J into the SAME JVM as the ERP core and every other pluginYour own separate process/container, called over plain HTTP
scopeglobal — an ERP-vendor/marketplace-style plugin, installed once, available to any tenant that installs ittenant — isolated to the one tenant that owns it
What it can doContribute a real extension point (RestContribution, DashboardWidgetContribution, ScheduledJobContribution, ...) — genuinely part of the platform's own request handlingRespond to ONE wire contract (POST /extension-api/{extensionPointCode}) the core calls with a timeout + circuit breaker
Trust barVetted/loaded like core code — no sandbox, full JVM access, affects every tenant with it installedThe core never loads it; a slow/broken service just degrades to "not extended"
Use it whenYou're building a plugin (JSON or code) that ANY tenant could install, same trust level as the platform's own modulesYou need to run code the platform can't vet in-process — a proprietary calculation, a call to your own internal systems, dependencies that would never be approved into the shared runtime

If you're not sure: a normal Java plugin (this guide) is the default for scope: global work. Reach for an L5 extension service only when the code genuinely must NOT run inside the shared ERP process — see that guide's own "What you're doing" for the full reasoning.

The complete example

Copy the real, runnable example at developer-docs/tutorial/code-plugin-example/ — don't hand-write one from scratch. It pairs:

  • Java: RiskScoreSummaryContribution — a real RestContribution (a live route, GET /api/v1/plugins/risk-score-widget/summary) that also implements DashboardWidgetContribution, computing a "risk score" over the tenant's real organization_tag rows with a plain JdbcTemplate query — the exact same arithmetic the JSON tutorial's data lives inside, just run in Java instead of read through the generic query engine.
  • React: RiskScoreSummaryPanel — real useState/useEffect, calls that SAME Java route with a plain fetch (there's no generic @erp/data hook for an arbitrary plugin-owned route — only for entity reads/writes), renders the real returned score.

Build and verify it yourself

bash
# 1. Java compiles standalone (own pom.xml, never added to the platform's
#    own Maven reactor — see the example's README for what a true
#    third-party build (no platform source) looks like)
mvn -f developer-docs/tutorial/code-plugin-example/java/pom.xml clean package

# 2. React type-checks and tests clean (extends the existing
#    erp-code-plugin-demo workspace package)
cd frontend/packages/erp-code-plugin-demo
pnpm typecheck && pnpm test

# 3. Package the .spk — auto-refreshes spk-assembly/lib/*.jar from the
#    Java build above, validates the page JSON
node tools/spark-cli/spark.js package developer-docs/tutorial/code-plugin-example/spk-assembly

# 4. Validate the manifest against the real schema
node tools/erp-cli/erp.mjs schema validate \
  developer-docs/tutorial/code-plugin-example/spk-assembly/plugin.json \
  --schema plugin-manifest

Installing it on a live ERP and re-running the React test against the real route is the example's own README's last step — it needs a running ERP, which this guide doesn't assume you have yet.

The parts that differ from a pure-JSON plugin

  • plugin.json's mainClass is a real fully-qualified class name, not null — see reference/plugin-manifest.md.
  • spk-assembly/lib/ carries your compiled jar (and only your own classes — engine-plugin-api/Spring dependencies are provided, resolved from the host's own classloader at runtime, never bundled).
  • Your Java class implements one of engine-plugin-api's extension-point interfaces (org.pf4j.ExtensionPoint subtypes), annotated @Extension. erp platform catalog lists every one that exists today.
  • Your React component is registered through registerExternal + registerCustomRenderer, not composed from @erp/ui primitives — see Add a custom block for that seam on its own (no Java), and concepts/sdk-modes.md for the full custom-component boundary (what IS and ISN'T sandboxed).
  • Getting the bundle onto a live tenant is erp plugin publish-frontend (uploads dist/browser.js, stamps plugin.json's frontendBundle) — a separate step from .spk packaging, run after install.

What this guide does NOT cover

  • Writing your OWN new extension-point interface — engine-plugin-api's existing interfaces are what you implement; adding a new one is platform work, not plugin work.
  • Wiring a DashboardWidgetContribution into Studio's actual Dashboard designer/renderer — real, shipped as an extension-point SHAPE, but the host-side rendering wire-up is a disclosed, not-yet-built platform gap (see that interface's own Javadoc). The example above still uses it to show the shape; the RestContribution route is what's reachable end to end today.
  • scope: tenant + Java — that's Level 5, the tenant extension service guide, a different deployable entirely.