# Standalone Plugins

Develop a plugin in its own repository with create-erxes-plugin


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](https://erxes.io/docs/create-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](https://erxes.io/docs/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](https://github.com/erxes/ui) | the UI: design-system primitives, hooks, state, `IUIConfig` |
| `ui-modules` | `@erxes/ui-modules` | [erxes/ui-modules](https://github.com/erxes/ui-modules) | the UI: business modules such as contacts, products, team members |
| `erxes-api-shared` | `@erxes/api-shared` | [erxes/api-shared](https://github.com/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:

```jsonc
// 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](https://erxes.io/docs/architecture-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:

```sh
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:

```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/erxes/create-erxes-plugin/main/install.sh | sh
```

```powershell
# 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](https://github.com/erxes/create-erxes-plugin).

## Generate and connect a plugin

### 1. Generate the repository

```bash
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:

```text
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](https://erxes.io/docs/plugin-backend) for the `startPlugin` options and [GraphQL & tRPC](https://erxes.io/docs/plugin-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:

```dotenv
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:

```bash
# 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.

```ts
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

```bash
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](https://erxes.io/docs/self-hosting) 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

- [Backend Plugins](https://erxes.io/docs/plugin-backend): startPlugin options, tenant models, meta dispatch
- [Frontend Plugins](https://erxes.io/docs/plugin-frontend): IUIConfig, federation exposes, host loading
- [Shared UI Components](https://erxes.io/docs/plugin-ui-components): Reuse erxes-ui and ui-modules
- [Shared Libraries](https://erxes.io/docs/architecture-shared-libraries): What erxes-api-shared, erxes-ui and ui-modules provide

