Standalone Plugins
Develop a plugin in its own repository, run it against a local erxes checkout, and deploy it as a separate API service and UI remote.
Monorepo or separate repository
Create a Plugin adds paired _api and _ui projects inside the erxes monorepo. A standalone plugin uses the same platform mechanisms from outside it: the API registers with the gateway through Redis service discovery and joins the federated supergraph, and the UI is a Module Federation remote that Core UI loads from the URL the API announces. Nothing in the erxes repository references the plugin except its entry in ENABLED_PLUGINS.
Choose a separate repository when the plugin has its own release cycle, its own team, or source code that does not belong in the erxes repository. Choose the monorepo when the plugin is part of erxes itself.
Prerequisites
- An erxes checkout on
mainrunning locally per Local Setup. Loading remotes from outside the monorepo in development and the packaged shared libraries are onmainand part of the next release. - Node.js 22. pnpm is the recommended package manager for the plugin repository: it resolves git dependencies on monorepo subdirectories, which the shared libraries rely on.
- MongoDB and Redis reachable from the plugin, using the same instances as your erxes checkout.
Install the CLI
create-erxes-plugin is a standalone binary; it does not need Node.js to run.
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/erxes/create-erxes-plugin/main/install.sh | sh
# Windows (PowerShell)
irm https://raw.githubusercontent.com/erxes/create-erxes-plugin/main/install.ps1 | iex
The installer picks /usr/local/bin when writable, otherwise ~/.local/bin; on Windows it uses %LOCALAPPDATA%\Programs\create-erxes-plugin and adds it to the user PATH. Pass --version <release> and --dir <path> after sh -s -- to pin a release or choose the directory. With Node.js installed, npx create-erxes-plugin runs the same CLI. The source is at erxes/create-erxes-plugin.
Generate and connect a plugin
Generate the repository
create-erxes-plugin inventory --backend platform --pm pnpmThe CLI asks for anything not passed as a flag:
Flag Default [directory]the plugin name -n, --name <name>the directory name -t, --title <title>the name in Title Case, shown in erxes navigation -d, --description <text><title> plugin for erxes-b, --backend <express|platform>asked interactively --pm <npm|pnpm|yarn|bun>the package manager running the CLI --api-port <port>3399--ui-port <port>3099--erxes-ref <ref>main; branch, tag, or commit oferxes/erxesfor the shared libraries--no-install,--no-gitdependencies are installed and git initruns-y, --yesaccept defaults for every unanswered question Plugin names are lowercase words separated by single dashes (
inventory,erxes-agent-v2). The generated repository:erxes.json plugin identity and ports, read by api/ and ui/ api/ the plugin API (see the next step) ui/ React Module Federation remote with prefixed Tailwind CSS Dockerfile API-only image for the chosen package manager README.md docs/erxes-integration.md how this plugin plugs into erxes, with its names filled inPick the backend stack
Stack What you get Runtime platformstartPluginfromerxes-api-shared: gateway registration,/health,/graphql, gateway-header parsing, tenant-scoped Mongoose models throughcreateGenerateModels,schemaWrapper, andcheckPermissionon the context. The same architecture as a monorepo plugin.Node.js expressExpress with an Apollo Server federated subgraph and no dependency on erxes-api-shared.api/src/gateway.tswrites the same Redis keysjoinErxesGatewaywrites, andapi/src/context.tsturns the gateway's forwarded headers into the GraphQL context.Node.js or Bun Use
platformfor plugins that store tenant data or call core modules;expresssuits thin integrations that only need to appear in the supergraph. Either way, every GraphQL type and operation carries the plugin prefix (Inventory,inventory) because all plugins share one supergraph. See Backend Plugins for thestartPluginoptions and GraphQL & tRPC for subgraph conventions.Understand the shared-library dependencies
erxes-ui,ui-modules, anderxes-api-sharedlive in the erxes monorepo (frontend/libs, backend/erxes-api-shared) and are not on npm yet. The generated manifests consume them as git dependencies on monorepo subdirectories:// ui/package.json "erxes-ui": "github:erxes/erxes#main&path:frontend/libs/erxes-ui", "ui-modules": "github:erxes/erxes#main&path:frontend/libs/ui-modules" // api/package.json (platform stack) "erxes-api-shared": "github:erxes/erxes#main&path:backend/erxes-api-shared"- The ref after
#(--erxes-ref) is what the plugin compiles and type-checks against. Pin it to the commit your erxes deployment runs: at runtime Core UI provideserxes-uiandui-modulesas Module Federation singletons, so a mismatch shows up as broken imports rather than a helpful error. - On install, each library runs its
preparescript:erxes-api-sharedbuilds its bundles and the UI libraries emit type declarations intodist/. TypeScript then checks the plugin against those declarations only. The generatedpnpm-workspace.yamllists the three packages underonlyBuiltDependenciesbecause pnpm blocks dependency build scripts by default. Expect the first install to take a few minutes; it resolves the erxes workspace once and caches it. - With npm, yarn, or bun the CLI writes
file:../erxes/...specifiers instead, which expect an erxes clone next to the plugin wherepnpm installhas run. - Import from the package root (
import { Button } from "erxes-ui"). The remote'shostSharedlist inui/rspack.config.tsmirrors Core UI's shared singletons (react,react-dom,react-router,react-router-dom,@apollo/client,jotai,react-i18next,erxes-ui,ui-modules) withimport: false: the remote never bundles them. A deep import such aserxes-ui/components/buttonwould bypass the singleton and bundle a second copy. - When the libraries are published to npm, replace the git specifiers with version ranges; nothing else changes.
- The ref after
Enable and run
In the erxes checkout, add the plugin name to
.envand restartpnpm dev:core-api(orpnpm dev:apis) andpnpm dev:uis:ENABLED_PLUGINS=sales,inventoryIn the plugin repository, copy
api/.env.exampletoapi/.env. The generated defaults point at local Redis and announce the UI dev server:REDIS_HOST=localhost REDIS_PORT=6379 MONGO_URL=mongodb://localhost:27017/erxes # platform stack UI_ENTRY_URL=http://localhost:3099/remoteEntry.jsThen start both parts:
pnpm dev:api # registers erxes-service-inventory and the config key in Redis pnpm dev:ui # serves http://localhost:3099/remoteEntry.jsCore API returns the registered
uiEntryfromGET /get-frontend-plugins, and Core UI in development registers remotes from that response alongside the monorepo's own. Openhttp://localhost:3001: the host loadsinventory_ui/config, adds the navigation entry, and routes/inventory/*to the remote. The remote's own port is never the app entry point.Check it works
pnpm check && pnpm lint && pnpm buildbuildemitsapi/distandui/dist/remoteEntry.js. Then check end to end:- Redis has
erxes-service-inventoryanderxesservice:config:inventory, and the gateway composes the plugin's subgraph. - The host renders the plugin's navigation and page without a console error about a missing remote.
- Queries and mutations work for a permitted user and update the UI immediately.
- Redis has
Deploy
The API and the UI deploy separately:
- API:
docker build --build-arg UI_ENTRY_URL=<remoteEntry.js URL> -t <image> .builds an API-only image that registers itself on start. The gateway reaches it atLOAD_BALANCER_ADDRESS, orhttp://plugin-inventory-api:<port>when that is unset, and the gateway's ownENABLED_PLUGINSmust list the plugin. - UI: upload
ui/distto any static host and pointUI_ENTRY_URLat itsremoteEntry.js. Cache hashed assets long andremoteEntry.jswithCache-Control: no-store. WithoutUI_ENTRY_URL, erxes looks for the remote atplugins.erxes.io/<release>/inventory_ui/remoteEntry.js, which only exists for plugins erxes publishes.
Production and development use the same discovery path, so a plugin that works locally against
pnpm dev:uisworks against a deployed Core UI once its API and remote are reachable. See Deployment for the erxes side.- API:
Names that must stay consistent
All of these come from erxes.json; change them only there, and only together.
| Name | Example | Used by |
|---|---|---|
name | inventory | Redis keys, ENABLED_PLUGINS, the docker host plugin-inventory-api, the CDN folder inventory_ui |
ui.name | inventory | CONFIG.name, the page expose ./inventory, permission checks |
ui.remote | inventory_ui | the Module Federation container; Core API derives it from name |
ui.path | inventory | the route /inventory/* in Core UI |
| GraphQL prefix | inventory / Inventory | operation, field, and type names, unique across the supergraph |
Core UI expects the remote to expose ./config (the IUIConfig export named CONFIG) and ./<ui.name>; the generated ui/rspack.config.ts reads both from erxes.json. The plugin's Tailwind classes use the inventory: prefix and the host's design tokens so styles do not collide with other remotes.
Troubleshooting
pnpm installfinishes buterxes-uitypes resolve to source files with errors: the library'spreparedid not run. Confirm the three packages are listed underonlyBuiltDependenciesinpnpm-workspace.yaml; withfile:specifiers, runpnpm installin the erxes clone first so itsdist/exists.- Core UI never shows the plugin: check that the name is in the erxes
ENABLED_PLUGINS, thatUI_ENTRY_URLis reachable from the browser, and that Core API restarted after the.envchange. Core UI waits only briefly for/get-frontend-pluginsin development, so a slow Core API means the remote is skipped until the next reload. - The page loads but hooks or routing fail: a shared library got bundled into the remote. Check that the
hostSharedlist still hasimport: falseand that imports use the package root, not deep paths. - The API starts but the gateway never composes it: check Redis reachability, the
erxes-service-inventorykey, and that the plugin's GraphQL types carry the prefix (a bareQueryfield name used by another plugin fails composition). bunwith theplatformstack stops with an error: that stack runs on Node.js only; useexpresswith Bun or switch to Node.js.