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 name 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 is onmainand part of the next release. - Node.js 22, or Bun for the Bun-based stacks. Any of npm, pnpm, yarn, or bun works as the plugin's package manager.
- MongoDB and Redis reachable from the plugin, using the same instances as your erxes checkout.
The shared libraries
The erxes shared libraries are published on npm and developed in their own repositories:
| Import name | npm package | Repository | Used by |
|---|---|---|---|
erxes-ui |
@erxes/ui |
erxes/ui | the UI: design-system primitives, hooks, state, IUIConfig |
ui-modules |
@erxes/ui-modules |
erxes/ui-modules | the UI: business modules such as contacts, products, team members |
erxes-api-shared |
@erxes/api-shared |
erxes/api-shared | the API on the platform stack: startPlugin, tenant models, core modules |
The generated manifests install them under the names erxes code imports, using npm aliases, so a plugin reads exactly like a monorepo plugin:
// ui/package.json
"erxes-ui": "npm:@erxes/ui@^1.0.0",
"ui-modules": "npm:@erxes/ui-modules@^1.0.0"
// api/package.json (platform stack)
"erxes-api-shared": "npm:@erxes/api-shared@^1.0.0"
Each package has its own semver version. Keep the plugin on the versions your erxes deployment is built with: at runtime Core UI provides erxes-ui and ui-modules as Module Federation singletons, so the installed copies only decide what TypeScript and Rspack resolve against, and a mismatch shows up as broken imports rather than a helpful error. See Shared Libraries for what the packages provide.
Install the CLI
The CLI is published as @erxes/create-plugin. Every package manager's create shorthand reaches it:
npm create @erxes/plugin@latest inventory
pnpm create @erxes/plugin inventory
yarn create @erxes/plugin inventory
bun create @erxes/plugin inventory
npx @erxes/create-plugin inventory
There is also a standalone binary that does not need Node.js:
# 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. The source is at erxes/create-erxes-plugin.
Generate and connect a plugin
1. Generate the repository
npm create @erxes/plugin@latest inventory -- --backend platform --pm pnpm
The 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 <stack> |
asked interactively: platform, or a standalone framework (express, fastify, hono, elysia, nestjs) |
--pm <npm|pnpm|yarn|bun> |
the package manager running the CLI; bun runs the API on Bun |
--api-port <port> |
3399 |
--ui-port <port> |
3099 |
--no-install, --no-git |
dependencies are installed and git init runs |
-y, --yes |
accept defaults for every unanswered question |
With bun create, pass --yes rather than -y, which Bun consumes itself. 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/
.env.example one .env at the root, read by both parts
api/ the plugin API (see the next step)
src/print-schema.ts prints the API's GraphQL schema for codegen
ui/ React Module Federation remote with prefixed Tailwind CSS
codegen.ts generates typed GraphQL documents from the printed schema
Dockerfile API-only image for the chosen package manager
.oxlintrc.json lint rules, including the codegen import guards
README.md commands and the rules that keep the plugin working
docs/erxes-integration.md how this plugin plugs into erxes, with its names filled in
2. Pick the backend stack
| Stack | What you get | Runtime |
|---|---|---|
platform |
startPlugin from erxes-api-shared: gateway registration, /health, /graphql, gateway-header parsing, tenant-scoped Mongoose models through createGenerateModels, schemaWrapper, and checkPermission on the context. The same architecture as a monorepo plugin. |
Node.js |
express |
Express 5 with an Apollo Server federated subgraph | Node.js or Bun |
fastify |
Fastify with an Apollo Server federated subgraph | Node.js |
hono |
Hono with a GraphQL Yoga federated subgraph | Node.js or Bun |
elysia |
Elysia with a GraphQL Yoga federated subgraph | Bun |
nestjs |
NestJS with the @nestjs/apollo federation driver, code-first |
Node.js |
The standalone frameworks have no dependency on erxes-api-shared: api/src/gateway.ts writes the same Redis keys joinErxesGateway writes, and api/src/context.ts turns the gateway's forwarded headers into the GraphQL context.
Use platform for plugins that store tenant data or call core modules; a standalone framework suits thin integrations that only need to appear in the supergraph, or teams that already run that framework. Either way, every GraphQL type and operation carries the plugin prefix (Inventory, inventory) because all plugins share one supergraph. See Backend Plugins for the startPlugin options and GraphQL & tRPC for subgraph conventions.
3. Configure and run
In the plugin repository, copy .env.example to .env. There is a single .env at the repository root; both api/ and ui/ read it. 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.js
Start the plugin API first, then erxes with the plugin enabled, then the plugin UI:
# 1. in the plugin repository
pnpm run dev:api # registers erxes-service-inventory and erxesservice:config:inventory in Redis
# 2. in the erxes checkout
ENABLED_PLUGINS=sales,inventory pnpm dev:core-api # gateway + Core API
ENABLED_PLUGINS=sales,inventory pnpm nx serve core-ui
# 3. back in the plugin repository
pnpm run dev:ui # runs codegen, then serves http://localhost:3099/remoteEntry.js
ENABLED_PLUGINS can also live in the erxes .env. Start the plugin API before the gateway: the gateway waits for every enabled plugin's address to appear in Redis before it composes the supergraph. Use pnpm dev:core-api and pnpm nx serve core-ui rather than pnpm dev:apis and pnpm dev:uis, which would also try to start a monorepo plugin of that name.
Open http://localhost:3001/inventory. The remote's own port is never the app entry point.
4. Understand how it connects
ENABLED_PLUGINS is a list of names, not paths. Every consumer turns a name into a Redis key and reads what the plugin wrote there:
| Who | What it does with inventory |
|---|---|
| plugin API | on start, writes erxes-service-inventory (its address) and erxesservice:config:inventory (including uiEntry from UI_ENTRY_URL) to Redis |
| gateway | for each name in ENABLED_PLUGINS, reads the address from Redis, fetches that subgraph's schema, and composes the supergraph |
| Core API | reads uiEntry from Redis and returns { name: "inventory_ui", entry } from GET /get-frontend-plugins |
| Core UI | loads remoteEntry.js from that URL, reads inventory_ui/config, adds the navigation entry, and routes /inventory/* to the remote |
Requests from the page reach the plugin through the gateway at /graphql, which forwards the signed-in user and tenant. The generated status query on the overview page requires a signed-in user, so it answers Login required to an anonymous request.
5. Work with GraphQL types
The generated repository follows the same schema-driven codegen as the erxes operation plugin. pnpm run codegen prints the API's subgraph SDL to api/generated/schema.graphql, then ui/codegen.ts (graphql-codegen client preset) validates every document under ui/src against it and generates typed documents into ui/src/gql/. Both folders are gitignored; check, build, and dev:ui run codegen first.
import { gql } from "~/gql";
export const STATUS_QUERY = gql(`
query inventoryStatus {
inventoryStatus { plugin version subdomain userEmail }
}
`);
const { data } = useQuery(STATUS_QUERY); // data.inventoryStatus is typed from the SDL
Write documents as gql() calls on static strings with no ${} interpolation, and let Apollo hooks infer their types: no useQuery<T> generics and no handwritten response interfaces. A field the SDL does not have, a wrong argument, or a nullable field treated as required fails check instead of surfacing as a runtime Apollo error. .oxlintrc.json rejects graphql-tag and gql from @apollo/client under ui/ so every document is one codegen sees. On the nestjs stack, register every resolver class in api/src/resolvers.ts; AppModule and print-schema.ts read the same list.
6. Check it works
pnpm run check && pnpm run lint && pnpm run build
check runs codegen and type-checks both parts; build emits api/dist and ui/dist/remoteEntry.js. Then check end to end:
- Redis has
erxes-service-inventoryanderxesservice:config:inventorywithuiEntry, and the gateway log shows the plugin joined service discovery. GET http://localhost:3300/get-frontend-pluginslistsinventory_uiwith yourUI_ENTRY_URL.- 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.
7. 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 works against a deployed Core UI once its API and remote are reachable. See Deployment for the erxes side.
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), ./<ui.name>, ./<ui.name>Settings, and ./notificationWidget; the generated ui/rspack.config.ts reads the names from erxes.json. The remote's hostShared list mirrors Core UI's shared singletons (react, react-dom, react-router, react-router-dom, @apollo/client, jotai, react-i18next, erxes-ui, ui-modules) with import: false: the remote never bundles them. Import from the package root (import { Button } from "erxes-ui"); a deep import such as erxes-ui/components/button would bypass the singleton and bundle a second copy. 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 installwarns thatui-moduleshas an unmeterxes-uipeer: pnpm cannot compare an aliased peer range. The peer is still linked to theerxes-uithe repository installs, so the warning is harmless.checkfails inside codegen: a document names a field or argument the SDL does not have, or two operations share a name. Fix the document, or the schema and its resolver together; the error lists the file and the offending selection.- 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 change. 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 gateway starts but never finishes composing: it is waiting for an enabled plugin whose
erxes-service-<name>key is missing. Start the plugin API, or remove the name fromENABLED_PLUGINS. - 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 rejects the subgraph: check that the plugin's GraphQL types carry the prefix. A bare
Queryfield name used by another plugin fails composition. bunwithplatform,fastify, ornestjsstops with an error: those stacks run on Node.js only. Useexpress,hono, orelysiawith Bun, or switch to Node.js.