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 main running locally per Local Setup. Loading remotes from outside the monorepo in development is on main and 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-inventory and erxesservice:config:inventory with uiEntry, and the gateway log shows the plugin joined service discovery.
  • GET http://localhost:3300/get-frontend-plugins lists inventory_ui with your UI_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 at LOAD_BALANCER_ADDRESS, or http://plugin-inventory-api:<port> when that is unset, and the gateway's own ENABLED_PLUGINS must list the plugin.
  • UI: upload ui/dist to any static host and point UI_ENTRY_URL at its remoteEntry.js. Cache hashed assets long and remoteEntry.js with Cache-Control: no-store. Without UI_ENTRY_URL, erxes looks for the remote at plugins.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 install warns that ui-modules has an unmet erxes-ui peer: pnpm cannot compare an aliased peer range. The peer is still linked to the erxes-ui the repository installs, so the warning is harmless.
  • check fails 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, that UI_ENTRY_URL is reachable from the browser, and that Core API restarted after the change. Core UI waits only briefly for /get-frontend-plugins in 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 from ENABLED_PLUGINS.
  • The page loads but hooks or routing fail: a shared library got bundled into the remote. Check that the hostShared list still has import: false and 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 Query field name used by another plugin fails composition.
  • bun with platform, fastify, or nestjs stops with an error: those stacks run on Node.js only. Use express, hono, or elysia with Bun, or switch to Node.js.

Next steps

Operate on one system.

Talk to our team about consolidating your stack onto erxes. 30-minute walkthrough, custom to your org.