PHX Frontend Plugin Demo
Example project showing how to build a PHX ERP frontend plugin as an Angular web component, using @phx/shared and @phx/shared-ui.
The same codebase supports two workflows:
| Mode | Purpose | Typical command |
|---|---|---|
| Plugin host | Run inside PHX as a custom element | yarn run plugin |
| Standalone client | Develop locally like a normal Angular app | yarn client |
Table of contents
- Overview
- Prerequisites
- Quick start
- Live demo
- Development modes
- Project structure
- Create your own plugin
- Additional topics
- Troubleshooting
Overview
A PHX frontend plugin is an Angular application packaged as a custom element (web component). PHX loads it via a manifest that points to your compiled main.js and declares the element tag name.
┌─────────────────┐ manifest.json ┌───────────────────┐
│ PHX host │ ─────────────────────> │ main.js (+ deps) │
│ (ERP shell) │ loads & registers │ custom element │
└────────┬────────┘ └────────┬──────────┘
│ │
│ pluginServices (Apollo, notifications…) │
│ hostInjector │
└──────────────────────────────────────────┘
What this demo includes:
- Custom element tag:
frontend-plugin-demo - Sample routes: hello world, product view, address list
- GraphQL queries via PHX host Apollo (production) or a local Apollo client (development)
- Login flow for standalone development without a pre-configured API key
- PrimeNG + Tailwind styling aligned with PHX
Prerequisites
- Node.js (LTS recommended)
- Yarn v4
- A running PHX instance (for plugin-host testing and GraphQL schema/codegen)
- An npm access token with read access to PHXGMBH packages — request one at your PHX partner
For local standalone development you also need either:
- A PHX API user token in your development environment, or
- Valid credentials for the built-in login screen (see Apollo, auth guard, and login)
Quick start
-
Configure Yarn to access the PHX npm registry (see PHX libraries). Do not commit
.yarnrc.yml. -
Install dependencies:
yarn install -
Create a local development environment (optional, for standalone mode):
cp src/environments/environment.example.ts src/environments/environment.development.tsAdjust
apiUrl,wsUrl, and optionallyapiKeyin that file. The file is gitignored. -
Generate GraphQL types (requires PHX admin API at the schema URL configured in
codegen.ts):yarn codegen -
Run in one of the development modes below.
Live demo
To use this demo in your own instance, add a custom element in Admin → Custom Elements (https://your.phx.instance/admin/customElements) with this manifest URL:
https://gitea.phx-erp.de/api/v1/repos/PHXGMBH/phx-frontend-plugin-webcomponent-demo/raw/master/latest/manifest.json
The manifest resolves to the compiled main.js in the latest/ directory of this repository.
Development modes
Standalone client (usual Angular workflow)
Runs the app with the development build configuration on port 4201 — routing, login, and a local Apollo client work without PHX.
yarn client
# equivalent: ng serve --port 4201 --watch --configuration development
Open http://localhost:4201/.
Plugin host mode (inside PHX)
Builds with the production configuration (no output hashing) and serves the output with CORS and no-cache headers so PHX can load fresh bundles during development.
yarn run plugin
This runs yarn build and yarn serve concurrently:
| Script | What it does |
|---|---|
yarn build |
Watches production build (in this repo, also syncs output to latest/ for the hosted demo) |
yarn serve |
Serves dist/.../browser/ at http://localhost:3223/ |
Point your PHX instance at the local manifest:
http://localhost:3223/manifest.json
(public/manifest.json is copied into the served output.)
Note: Use
yarn run plugin, notyarn plugin— Yarn treatspluginas a built-in command.
Terminal Keeper is preconfigured in .vscode/sessions.json to start both yarn run plugin and yarn client.
Other useful commands
yarn start # ng serve with development configuration (default port 4200)
yarn codegen # Regenerate GraphQL types
Project structure
phx-frontend-plugin-demo/
├── latest/ # Published build output (main.js, manifest.json)
├── public/
│ └── manifest.json # Local manifest (path → localhost:3223)
├── scripts/
│ ├── copy-latest.mjs # (this repo only) Sync dist/*.js → latest/ for hosted demo
│ └── serve-dist.mjs # Static server for plugin-host dev
├── src/
│ ├── app/
│ │ ├── components/ # Demo pages (hello-world, product-view, …)
│ │ ├── login/ # Login form (standalone development)
│ │ ├── services/
│ │ │ ├── apollo.service.ts
│ │ │ └── phoenix-host-bridge.service.ts
│ │ ├── app.config.ts # providePhoenixPluginWithPrimeNG, providers
│ │ ├── app.routes.ts
│ │ ├── apollo.provider.ts # Local Apollo (development only)
│ │ └── auth-guard.ts
│ ├── graphql/ # GraphQL documents for codegen
│ ├── environments/
│ │ ├── environment.ts # Production defaults (used in PHX)
│ │ ├── environment.example.ts # Template for local dev
│ │ └── environment.development.ts # Local overrides (gitignored)
│ └── main.ts # Registers the custom element
├── codegen.ts
├── serve.json # Cache headers for serve-dist.mjs
└── tailwind.config.js
Create your own plugin
The steps below walk through creating a project similar to this demo, using Yarn v4, Angular 20, PrimeNG 20, and Tailwind CSS.
Replace placeholders such as *PROJECT-NAME*, *YOUR-TAG*, and *YOUR-TOKEN* with your values.
1. Basic setup
mkdir *PROJECT-NAME*
cd *PROJECT-NAME*
npx @angular/cli@20 new *PROJECT-NAME* --directory ./ --package-manager yarn --style scss --ssr false --routing --standalone
yarn add primeng@20 @primeng/themes@20 tailwindcss@3.4.17 postcss tailwindcss-primeui @angular/animations@20 @angular/elements@20
yarn add -D typescript@5.9.2 @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node concurrently
ng add apollo-angular
2. Tailwind CSS
Create tailwind.config.js in the project root. This configuration is aligned with PHX:
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'selector',
content: ['./src/**/*.{html,ts,scss}'],
theme: {
extend: {
animation: {
fadein: 'fadein 0.5s ease-in-out',
fadeout: 'fadeout 0.5s ease-in-out',
fadeinleft: 'fadeinleft 0.5s ease-in-out',
fadeinright: 'fadeinright 0.5s ease-in-out',
fadeintop: 'fadeintop 0.5s ease-in-out',
fadeinbottom: 'fadeinbottom 0.5s ease-in-out',
},
keyframes: {
fadein: { '0%': { opacity: 0 }, '100%': { opacity: 1 } },
fadeout: { '0%': { opacity: 1 }, '100%': { opacity: 0 } },
fadeinleft: { '0%': { opacity: 0, transform: 'translateX(-100%)' }, '100%': { opacity: 1, transform: 'translateX(0)' } },
fadeinright: { '0%': { opacity: 0, transform: 'translateX(100%)' }, '100%': { opacity: 1, transform: 'translateX(0)' } },
fadeintop: { '0%': { opacity: 0, transform: 'translateY(-100%)' }, '100%': { opacity: 1, transform: 'translateY(0)' } },
fadeinbottom: { '0%': { opacity: 0, transform: 'translateY(100%)' }, '100%': { opacity: 1, transform: 'translateY(0)' } },
},
},
},
plugins: [require('tailwindcss-primeui')],
};
Due to a known issue, add these directives to each component's styles (and to src/styles.scss for local development):
@tailwind base;
@tailwind components;
@tailwind utilities;
3. GraphQL Codegen
Create codegen.ts in the project root (adjust the schema URL if your PHX instance runs elsewhere):
import type { CodegenConfig } from '@graphql-codegen/cli';
const sharedConfig = {
scalars: { DateTime: 'Date' },
immutableTypes: false,
} as const;
const config: CodegenConfig = {
overwrite: true,
schema: 'http://localhost:3000/admin-api/schema.gql',
documents: './src/graphql/*.ts',
ignoreNoDocuments: true,
generates: {
'./src/app/schema-types.ts': {
plugins: ['typescript'],
config: sharedConfig,
},
'./src/app/generated.ts': {
plugins: ['typescript-operations', 'typed-document-node'],
config: {
...sharedConfig,
importSchemaTypesFrom: './src/app/schema-types.ts',
},
},
},
};
export default config;
Add to package.json:
"codegen": "graphql-codegen"
Define queries and mutations in src/graphql/, then run:
yarn codegen
This generates src/app/schema-types.ts and src/app/generated.ts.
4. Environments
Use separate environments so the same build runs as a PHX plugin (production) or as a standalone dev app (development).
src/environments/environment.interface.ts
export abstract class Environment {
production: boolean = false;
apiUrl: string | undefined;
wsUrl: string | undefined;
apiKey: string | undefined;
serverUrl: string = '';
}
src/environments/environment.ts (production — used when embedded in PHX)
import { Environment } from './environment.interface';
export const environment: Environment = {
production: true,
apiUrl: undefined,
wsUrl: undefined,
apiKey: undefined,
serverUrl: '',
};
src/environments/environment.development.ts (local development — add to .gitignore)
import { Environment } from './environment.interface';
export const environment: Environment = {
production: false,
apiUrl: 'http://localhost:3000/admin-api',
wsUrl: 'ws://localhost:3000/admin-api',
apiKey: undefined, // or a PHX API user token; otherwise use the login route
serverUrl: 'https://localhost:4200',
};
Add fileReplacements to the development build configuration in angular.json:
"development": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
Tip: Ship
environment.example.tsin the repo and copy it toenvironment.development.tslocally, as this demo does.
5. PHX libraries
@phx/shared and @phx/shared-ui are published on the PHXGMBH npm registry.
Create .yarnrc.yml in the project root (or edit ~/.yarnrc.yml for a global setup):
nodeLinker: node-modules
npmScopes:
phx:
npmRegistryServer: "https://gitea.phx-erp.de/api/packages/PHXGMBH/npm/"
npmAuthToken: "*YOUR-TOKEN*"
Important: Add
.yarnrc.ymlto.gitignoreif it contains your token.
Then install the libraries:
yarn add @phx/shared @phx/shared-ui
6. Plugin setup
Application config
Add providePhoenixPluginWithPrimeNG in src/app/app.config.ts:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { providePhoenixPluginWithPrimeNG } from '@phx/shared-ui';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
// stripTrailingSegments: child route segments appended by PHX deep links
// so APP_BASE_HREF still matches the host mount path.
...providePhoenixPluginWithPrimeNG({
stripTrailingSegments: ['*YOUR*', '*ROUTES*', '*HERE*'],
}),
provideRouter(routes),
],
};
Routing: Pass your route path segments in
stripTrailingSegments, or derive them automatically:stripTrailingSegments: routes.map((r) => r.path!).filter((p) => (p?.length ?? 0) > 0)
Host bridge service
Create src/app/services/phoenix-host-bridge.service.ts:
import { Injectable, Injector, signal } from '@angular/core';
import type { IPluginServices } from '@phx/shared-ui';
@Injectable({ providedIn: 'root' })
export class PhoenixHostBridgeService {
private readonly _hostInjector = signal<Injector | null>(null);
private readonly _pluginServices = signal<IPluginServices | null>(null);
hostInjector(): Injector | null {
return this._hostInjector();
}
setHostInjector(injector: Injector): void {
this._hostInjector.set(injector);
}
pluginServices(): IPluginServices | null {
return this._pluginServices();
}
setPluginServices(services: IPluginServices): void {
this._pluginServices.set(services);
}
}
The host bridge stores services injected by PHX (Apollo client, notification service, etc.) so your components can use them in production mode.
Root component
Your root component receives pluginServices and hostInjector from PHX and forwards them to the bridge:
import { Component, effect, inject, Injector, input } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { PhoenixHostBridgeService } from './services/phoenix-host-bridge.service';
import { IPluginServices, syncPhoenixHostInjector } from '@phx/shared-ui';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `<router-outlet />`,
styles: [`
@tailwind base;
@tailwind components;
@tailwind utilities;
`],
})
export class App {
private readonly hostBridge = inject(PhoenixHostBridgeService);
readonly pluginServices = input<IPluginServices>({});
readonly hostInjector = input<Injector | undefined>(undefined);
private readonly _syncHostInjector = syncPhoenixHostInjector(this.hostBridge, this.hostInjector);
private readonly _syncPluginServices = effect(() => {
this.hostBridge.setPluginServices(this.pluginServices());
});
}
Bootstrap / custom element registration
In src/main.ts, register your plugin as a custom element (replace *YOUR-TAG*):
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { bootstrapPhoenixPluginCustomElement } from '@phx/shared-ui';
import { environment } from './environments/environment';
bootstrapPhoenixPluginCustomElement(App, '*YOUR-TAG*', appConfig).then((app) => {
if (!environment.production) {
return app!.bootstrap(App);
}
return app;
});
bootstrapPhoenixPluginCustomElement— creates the Angular application and registers the custom element for PHX.app.bootstrap(App)in development — additionally mounts the root component song serve/ standalone mode works with routing.
The custom element tag must be lowercase with hyphens (e.g. my-company-orders). Use the same tag in your manifest.
7. Register in PHX
Manifest format
PHX loads a JSON manifest that points to your entry script and declares the custom element tag:
{
"path": "https://example.com/path/to/main.js",
"items": [
{
"tagName": "*YOUR-TAG*"
}
]
}
| Field | Description |
|---|---|
path |
Absolute URL to main.js (and base for chunk resolution) |
items[].tagName |
Custom element tag registered in main.ts |
Local development example (public/manifest.json in this repo):
{
"path": "http://localhost:3223/main.js",
"items": [{ "tagName": "frontend-plugin-demo" }]
}
Hosted example (latest/manifest.json in this repo):
{
"path": "https://gitea.phx-erp.de/api/v1/repos/PHXGMBH/phx-frontend-plugin-webcomponent-demo/raw/master/latest/main.js",
"items": [{ "tagName": "frontend-plugin-demo" }]
}
Registration steps
- Host
main.js(and any sibling chunks) at a URL reachable from your users' browsers — same network rules as your PHX instance. - Publish a manifest JSON at a stable URL.
- In PHX: Admin → Custom Elements → add the manifest URL and choose a mount path or tag.
- Log out and back in. The plugin is available at
https://your.phx.instance/customElements/*PATH*.
For local plugin-host development, see Serving the plugin locally.
Additional topics
Serving the plugin locally
Install the static server:
yarn add -D serve
scripts/serve-dist.mjs
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const port = process.env.PORT ?? '3223';
const serveBin = join(root, 'node_modules', '.bin', 'serve');
const child = spawn(serveBin, ['-l', port, '--cors', '--no-etag'], {
stdio: 'inherit',
shell: true,
cwd: root,
});
child.on('exit', (code) => process.exit(code ?? 0));
serve.json (replace *PROJECT-NAME* with your Angular project name from angular.json)
{
"public": "dist/*PROJECT-NAME*/browser",
"headers": [
{
"source": "**/*.{js,mjs}",
"headers": [
{ "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate" }
]
}
]
}
Serving from the project root (not directly from dist/) ensures serve.json is applied and avoids stale cached main.js after rebuilds.
Add to package.json:
"serve": "node ./scripts/serve-dist.mjs"
Use a custom port: PORT=8080 yarn serve.
This repository only:
scripts/copy-latest.mjscopies built JS files intolatest/so the live demo manifest can point at a stable path in git. You do not need this for local plugin-host development.
Apollo, auth guard, and login
In production (embedded in PHX), authentication is handled by the host. Use the Apollo client from IPluginServices via PhoenixHostBridgeService — you do not need a separate login flow.
In development (standalone), provide your own Apollo client and optional login.
Apollo provider
Create src/app/apollo.provider.ts (see this repository for the full implementation). It:
- Connects HTTP and WebSocket links to
environment.apiUrl/environment.wsUrl - Sends
Authorization: Bearer …fromenvironment.apiKeyorlocalStorage - Persists tokens from the
phoenix-auth-tokenresponse header
Register it only in development, e.g. in app.config.ts:
...(environment.production ? [] : [...apolloProvider()])
Apollo service abstraction
Use a small service to pick the host Apollo client in production and the local client in development:
@Injectable({ providedIn: 'root' })
export class ApolloService {
private readonly _apollo = signal<Apollo>(undefined!);
constructor(private readonly injector: Injector) {
if (environment.production) {
this._apollo.set(injector.get(PhoenixHostBridgeService)?.pluginServices()?.apollo!);
} else {
this._apollo.set(
injector.get(PhoenixHostBridgeService)?.pluginServices()?.apollo ?? injector.get(Apollo)
);
}
}
apollo = () => this._apollo();
}
Apply the same pattern for other host services (e.g. notifications) when you need standalone fallbacks.
Login component
For development without a preset API key, add a /login route that calls the PHX login mutation:
mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
... on CurrentUser {
id
identifier
channels {
id
token
}
}
... on InvalidCredentialsError {
errorCode
message
}
... on NativeAuthStrategyError {
errorCode
message
}
... on EmailCodeAuthStrategyError {
errorCode
message
}
}
}
See src/app/login/ in this repo for a complete form implementation.
Auth guard
Redirect unauthenticated users to login in development only:
@Injectable()
export class AuthGuard implements CanActivate {
private readonly router = inject(Router);
canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): boolean {
const token = environment.apiKey ?? localStorage.getItem('api-key');
if (!environment.production && !token) {
this.router.navigate(['login'], {
queryParams: { redirectTo: btoa(window.location.pathname + window.location.search) },
});
}
return true;
}
}
Wire it in app.routes.ts:
const canActivate = [environment.production ? () => true : AuthGuard];
export const routes: Routes = [
{ path: '', canActivate, component: HelloWorld },
{ path: 'product-view', canActivate, component: ProductView },
{ path: 'address-list', canActivate, component: AddressList },
{ path: 'login', component: Login },
];
package.json scripts
Recommended scripts after following this guide:
{
"scripts": {
"build": "ng build --watch --output-hashing none --configuration production",
"serve": "node ./scripts/serve-dist.mjs",
"plugin": "concurrently \"yarn build\" \"yarn serve\"",
"client": "ng serve --port 4201 --watch --configuration development",
"codegen": "graphql-codegen"
}
}
| Script | Description |
|---|---|
build |
Production watch build for PHX |
serve |
Serves compiled assets for PHX (default port 3223) |
plugin |
Runs build + serve together — use yarn run plugin |
client |
Standalone Angular dev server on port 4201 |
codegen |
Regenerates GraphQL TypeScript types |
This repository additionally runs copy-latest.mjs alongside build to publish artifacts into latest/ for the hosted demo — that is not required for your own plugin.
Troubleshooting
| Symptom | Things to check |
|---|---|
| PHX shows an old version of the plugin | Hard-refresh; confirm serve.json sets Cache-Control: no-store for JS; restart yarn run plugin |
401 / GraphQL auth errors in standalone mode |
Set apiKey in environment.development.ts or log in via /login |
yarn add @phx/shared fails |
Verify .yarnrc.yml token; contact your PHX partner |
| Routing broken inside PHX | Add route segments to stripTrailingSegments in providePhoenixPluginWithPrimeNG |
| Tailwind classes missing in a component | Add @tailwind directives to that component's styles |
yarn codegen fails |
Ensure PHX is running and the schema URL in codegen.ts is reachable |
| Custom element not found | Tag in manifest must exactly match customElements.define / bootstrapPhoenixPluginCustomElement |
Support
For registry access, integration questions, or PHX-specific APIs, contact your PHX partner.