27 KiB
PHX Frontend Plugin Demo
Example project showing how to build a PHX ERP frontend plugin as an Angular web component, using @phx-erp/shared and @phx-erp/shared-ui.
Starting a new plugin? Use @phx-erp/create-phx-frontend-plugin to scaffold a project from the official template. This repository is a full demo you can explore, clone, or use as a reference.
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)
- PHX login redirect and callback route 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)
For local standalone development you also need either:
- A PHX API user token in your development environment, or
- Access to the PHX login page (see Apollo, auth guard, and login)
Quick start
These steps apply to this demo repository. To create your own plugin from scratch, use the scaffold CLI — see Create your own plugin.
- Install dependencies:
yarn install
- Create a local development environment (optional, for standalone mode):
cp src/environments/environment.example.ts src/environments/environment.development.ts
Adjust apiUrl, wsUrl, and optionally apiKey in that file. The file is gitignored.
3. 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://unpkg.com/@phx-erp/phx-frontend-plugin-demo/manifest.json
The manifest's path field points at the package root; unpkg serves main.js via the "." export. For local development with yarn run plugin, register http://localhost:3223/local.manifest.json in Admin → Custom Elements instead.
Development modes
Standalone client (usual Angular workflow)
Runs the app with the development build configuration on port 4201 — routing, PHX login redirect, and a local Apollo client work without embedding the plugin in 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 |
yarn serve |
Serves dist/.../browser/ at http://localhost:3223/ |
Point your PHX instance at the local manifest:
http://localhost:3223/local.manifest.json
(public/local.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/
├── public/
│ └── local.manifest.json # Local dev manifest (copied into dist)
├── manifest.json # Published manifest (npm/unpkg)
├── scripts/
│ └── serve-dist.mjs # Static server for plugin-host dev
├── src/
│ ├── app/
│ │ ├── components/ # Demo pages (hello-world, product-view, …)
│ │ ├── login.ts # Auth callback route — redirect flow (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
Scaffold CLI (recommended)
The fastest way to start is the official scaffold CLI, published as @phx-erp/create-phx-frontend-plugin:
# Yarn Berry — one-shot, no global install
yarn dlx @phx-erp/create-phx-frontend-plugin my-orders-plugin
# npm
npx @phx-erp/create-phx-frontend-plugin@latest my-orders-plugin
This copies the official plugin template, replaces placeholders (project name, custom element tag, API URLs), creates environment.development.ts, and can run yarn install. See the package page for non-interactive flags (--yes, --install, --api-url, --ui-url, --api-key, etc.).
Manual setup (step by step)
If you prefer to understand each piece or customize from scratch, 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 redirects to PHX login
serverUrl: 'https://localhost:4200', // PHX instance used for standalone login redirect
};
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-erp/shared and @phx-erp/shared-ui are published on the public npm registry.
Install the libraries:
yarn add @phx-erp/shared @phx-erp/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-erp/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-erp/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-erp/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-erp/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/local.manifest.json in this repo):
{
"path": "http://localhost:3223/main.js",
"items": [{ "tagName": "frontend-plugin-demo" }]
}
Published example (root manifest.json, served from npm via unpkg):
{
"path": "https://unpkg.com/@phx-erp/phx-frontend-plugin-demo",
"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.
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 redirect unauthenticated users to the PHX login page. PHX redirects back to a local /login callback route that stores the token and returns the user to the original page.
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 callback route
For development without a preset API key, you need a way to obtain and store an API token. This demo uses a redirect-based auth callback on /login rather than an embedded login form, but either approach is valid — you can also build a login form in your plugin, call the GraphQL login mutation, and handle the token yourself.
With the redirect flow, when the user is unauthenticated, the auth guard sends them to the PHX login page (environment.serverUrl). After successful login, PHX redirects back to your plugin's /login route with an authToken query parameter and a base64-encoded redirectTo target. The callback component stores the token in localStorage and navigates back to the original page:
@Component({
selector: 'app-login',
template: `
<div class="flex flex-col items-center justify-center h-screen w-screen opacity-20">
<div class="animate-fadein duration-1000 animate-infinite animate-alternate text-4xl">Signing in...</div>
</div>
`,
})
export class Login {
constructor(private readonly route: ActivatedRoute) {
this.route.queryParams.subscribe((params) => {
const token = params['authToken'];
if (token) {
const redirectTo = decodeURIComponent(atob(params['redirectTo']));
localStorage.setItem('api-key', token);
window.history.replaceState({}, '', redirectTo);
window.location.href = redirectTo;
}
});
}
}
See src/app/login.ts in this repo for the full implementation.
Auth guard
Redirect unauthenticated users to the PHX login page in development only. The redirectTo query parameter chains back through your plugin's /login callback:
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): boolean {
const token = environment.apiKey ?? localStorage.getItem('api-key');
if (!environment.production && !token) {
const redirectTo = encodeURIComponent(
btoa(`${window.location.protocol}//${window.location.host}/login?redirectTo=${encodeURIComponent(btoa(window.location.href))}`)
);
window.location.href = `${environment.serverUrl}/login?redirectTo=${redirectTo}`;
return false;
}
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 |
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 ensure serverUrl points to your PHX instance so the login redirect works |
yarn add @phx-erp/shared fails |
Check network access to the public npm registry; retry yarn install |
| 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 integration questions or PHX-specific APIs, contact your PHX partner.