Hands-On: Build a MIB Extension End-to-End
This is the build-it-yourself tour. By the end you'll have a running
Media-iBox CMS locally with two custom mediatypes (MOVIES, ASSETS),
a custom C# backend component, and a custom React widget that talks to
it — all wired through Docker Compose, ADM metadata, and Module
Federation.
This walkthrough doubles as a working reference. The directory
hands-on-files/ ships every artifact below
(docker-compose.yml, migrations, sample C# project, sample React
remote) so you can copy-paste and run. For a much larger live example
that follows the same pattern, see
mini-agiletv —
this tutorial is essentially the mini version of that project.
Verified end-to-end on macOS 14 (Apple Silicon, Docker Desktop 29.2, SQL Server 2022 ARM-emulated). Migrations apply cleanly (~600 framework rows seeded), the six services boot, OAuth2 SSO completes, and the React shell renders the Welcome screen. The exact gotchas that tripped me up the first time — and the fixes — are inlined as
live-run notecall-outs throughout the tutorial.
What this isn't. It's not a "click here in the admin UI" tour — for that, read the Built-in Components docs. This tutorial is for the developer adding new tables, new server logic, and new widgets.
What you'll build
By the end of this tutorial:
- A running Media-iBox CMS at
https://frontend.localtest.mewith SQL Server, Authorization Server, Permission MS, MIB API, FrontEnd Server (the BFF), and the React shell, all behind a single nginx reverse proxy. - Two custom tables / mediatypes:
HANDSON_MOVIES— a movie catalog with title, description, release year, rating.HANDSON_ASSETS— media files attached to movies (the related-list child).
- Admin UI: a sidebar menu group "Hands-On Catalog" → list pages for Movies and Assets, edit pages with related lists, etc. All from database rows, no React code yet.
- A custom React widget —
MovieDashboard— that pulls a small summary from a custom BFF component and renders it on the movie edit page, alongside the stock form. - A header action ("Re-index all movies") and an item action ("Re-index this movie") on the movies list, both calling a small backend endpoint we'll add.
- All of it visible in the React shell with hot-reload working for the custom React side, and a one-command Docker build for the custom C# side.
Architecture overview
flowchart TD
Browser([Browser])
Browser -->|HTTPS| Nginx[nginx<br/>localtest.me TLS]
Nginx -->|/auth| Auth[mib-cms-auth<br/>OAuth2]
Nginx -->|/api/v2/...| BFF[mib-cms-frontend<br/>BFF .NET 8]
Nginx -->|static| Shell[mib-cms-frontend-react<br/>nginx+React]
Nginx -->|/custom-components/...| Remote[custom-components-host<br/>nginx serving remoteEntry.js]
Nginx -->|/api/...| Api[mib-cms-api<br/>Data API]
BFF -->|HTTP| Api
BFF -->|HTTP| Auth
BFF -->|HTTP| Perm[mib-cms-permission<br/>Permission MS]
Api -->|TDS 1433| Sql[(SQL Server)]
Auth -->|TDS 1433| Sql
BFF -->|TDS 1433| Sql
Perm -->|TDS 1433| Sql
Shell -.->|import remoteEntry.js| Remote
subgraph customisations
BFF -.loads.-> Overlay[Acme.Mib.HandsOn.dll<br/>copied into /app/]
Remote -.serves.-> RemoteSrc[HandsOnRemote<br/>React + Vite federation]
end
Three customisation surfaces sit on top of the base stack:
| Surface | What you provide | Mount point |
|---|---|---|
| Database | SQL tables + ADM rows (MEDIA_TYPES, ADM_FIELDS, ADM_RELATEDS) + UI rows (MIB3UX_*) |
Run migrations against the same mib database the BFF uses |
| Backend overlay | One or more .dll files containing your IRenderableComponentV2 classes |
Flat into the FrontEnd Server image's /app/ |
| Federated remote | A built Vite app exposing ./all from src/main.tsx |
Served by a separate nginx, URL is given to the shell via CUSTOM_COMPONENTS_URL env var |
Each surface is independent — you can deploy a database-only change without rebuilding any DLL, and you can ship a new widget without running another migration.
Prerequisites
| Tool | Version |
|---|---|
| Docker Desktop | 4.30+ (for host.docker.internal) |
| Docker Compose | 2.27+ |
| .NET 8 SDK | 8.0+ (to build the custom backend project) |
| Node.js | 20+ (LTS) |
| pnpm or npm | latest |
| Access to ECR | Pull permission for 873339144623.dkr.ecr.us-east-1.amazonaws.com/agile/* images |
For TLS on *.localtest.me (which resolves to 127.0.0.1 via public
DNS), you'll generate a self-signed cert in Step 2 using the bundled
generate-certs.sh script.
Note on
localtest.me. All*.localtest.mesubdomains resolve to127.0.0.1via public DNS — you don't need to edit your/etc/hosts. This lets the BFF use HTTPS-only cookies locally without the OAuth redirect chain choking on a non-HTTPS callback URL.
The 10-minute happy path
If you trust the bundled artifacts, run these commands in order — they take ~10 minutes total and end with a logged-in MIB shell. Each step is explained in detail below.
# Clone or copy the artifact dir somewhere outside the docs repo.
cp -R MibDocs/cms/frontend_server/hands-on-files ~/handson-tutorial
cd ~/handson-tutorial
# 1. Build MibMigrator (one-time)
git clone git@github.com:agilecontent/MibMigrator.git ~/MibMigrator
( cd ~/MibMigrator && dotnet build -c Release )
# 2. Pull MIB images (ECR for the .NET services, GHCR for the React shell)
./compose/pull-images.sh # or follow Step 1 manually
# 3. Build the federated-remote image (custom React widgets)
( cd compose && cd ../frontend && \
docker build --build-arg NPM_AUTH_TOKEN=$GH_PAT \
-t handson-custom-components:local . )
# 4. Generate self-signed certs + trust them in the macOS keychain
bash ./compose/generate-certs.sh
# 5. Start SQL Server and create the database
( cd compose && docker compose up -d sqlserver )
bash ./compose/bootstrap-databases.sh
# 6. Apply the seven canonical MIB migrations (~3 minutes)
MIB_MIGRATOR_DIR=~/MibMigrator bash ./migrator-config/run-canonical-migrations.sh
# 7. Seed the OAuth clients (mibapi / mibfrontend / mibbackend)
bash ./compose/seed-oauth-clients.sh
# 8. Apply the custom-table migrations (movies + assets + UI pages)
( cd migrations && npm install --silent && \
./node_modules/.bin/knex migrate:latest --env mib && \
./node_modules/.bin/knex migrate:latest --env cms )
# 9. Seed AUTH_PERMISSIONS so the Hands-On menu is visible after login
bash ./compose/seed-permissions.sh
# 10. Bring up the rest of the services (BFF, React shell, custom-components, nginx)
( cd compose && docker compose up -d )
# 11. Verify every endpoint responds 200
for u in auth api backend frontend; do
curl -k -s -o /dev/null -w "$u: %{http_code}\n" \
-m 10 https://$u.localtest.me/$(test "$u" = frontend && echo "" || echo "health")
done
curl -k -s -o /dev/null -w "shell : %{http_code}\n" https://frontend.localtest.me/app/
curl -k -s -o /dev/null -w "custom : %{http_code}\n" https://frontend.localtest.me/custom-components/assets/remoteEntry.js
# 12. (Part 3 only — needs the C# DLL from Step 13) Build the custom
# backend component + apply the dashboard registration migration:
# ( cd backend && dotnet build -c Release )
# cp backend/bin/Release/net8.0/Acme.Mib.HandsOn.dll compose/plugins/
# ( cd migrations && ./node_modules/.bin/knex migrate:latest --env dashboard )
# ( cd compose && docker compose restart mib-cms-frontend )
# 13. Open https://frontend.localtest.me/app/ and log in:
# username: Administrator
# password: mediaibox
The rest of this tutorial walks each of those steps in detail.
Part 1 — Bring up the base stack
Step 1: Pull and tag MIB images
# 1.1 Authenticate against ECR once per shell session (for the four
# .NET backend services).
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin \
873339144623.dkr.ecr.us-east-1.amazonaws.com
# 1.2 Pull the four backend services — all pinned to 6.0.97.
docker pull 873339144623.dkr.ecr.us-east-1.amazonaws.com/agile/mib-cms-auth:6.0.97
docker pull 873339144623.dkr.ecr.us-east-1.amazonaws.com/agile/mib-cms-api:6.0.97
docker pull 873339144623.dkr.ecr.us-east-1.amazonaws.com/agile/mib-cms-microservices-permission:6.0.97
docker pull 873339144623.dkr.ecr.us-east-1.amazonaws.com/agile/mib-cms-frontend:6.0.97
# 1.3 Authenticate against GHCR for the React shell. You need a GitHub
# Personal Access Token with `read:packages` scope.
echo "$GH_PAT" | docker login ghcr.io -u <your-github-username> --password-stdin
# 1.4 Pull the React shell — the stock published image. The bundle is
# built with `base=/app/`, includes the AI Assistant panel, and
# ships its own SPA-fallback nginx config — no local build needed.
docker pull ghcr.io/agilecontent/mib-frontend:2.38.0
live-run note— image versions pinned here. Earlier drafts of this tutorial pinned the BFF to 6.0.90 because 6.0.91 / 6.0.96 crashed at startup withArgumentNullException: Value cannot be null. (Parameter 'httpClient')inMibSecurityTokenValidator..ctor. 6.0.97 fixes that and boots cleanly. All four backend services share the 6.0.97 tag. The React shell is on its own release train (ghcr.io/agilecontent/mib-frontend:2.38.0).
live-run note— no local React shell build. Earlier drafts builthandson-mib-cms-frontend-react:localfromMibFrontEndsource or re-tagged mini-agiletv's compose build. That path drifts behind the official release train and silently ships an older bundle (missing the AI Assistant panel, missingbase=/app/wiring, etc.) — use the GHCR image instead.
Step 2: Drop the docker-compose.yml
Copy hands-on-files/compose/docker-compose.yml
to your working directory. The complete file is too long to inline here;
the important parts:
- Seven services: sqlserver, mib-cms-auth, mib-cms-permission,
mib-cms-api, mib-cms-frontend (BFF), mib-cms-frontend-react
(React shell), mib-cms-custom-components (the federated
remote — built from
hands-on-files/frontend/Dockerfile, served by its own nginx behind the gateway), plus the outer nginx. - All services connect to the SAME database —
handson_mib. See thelive-run notein Part 2 for why we abandoned the two-database split. mib-cms-frontend:6.0.97(and the same tag formib-cms-auth,mib-cms-api,mib-cms-microservices-permission) — see the live-run note in Step 1.ghcr.io/agilecontent/mib-frontend:2.38.0— the stock React shell image pulled in Step 1.4. Built withbase=/app/, ships the AI Assistant panel, includes the SPA-fallback nginx config.- SQL Server healthcheck — every .NET service waits for
sqlserver: { condition: service_healthy }. Without it, the BFF races SQL Server's TLS pre-login and crashes withUnknown error 203(a Rosetta-on-Apple-Silicon flake). The BFF also hasrestart: unless-stoppedso an unlucky cold-start self-heals. TrustServerCertificate=true/Encrypt=false— set on every .NET service viaMIBDATABASECONFIG_DEFAULT_TRUSTSERVERCERTIFICATEandMIBDATABASECONFIG_DEFAULT_ENCRYPT. Skips the brittle TLS pre-login negotiation entirely. Safe here: the connection never leaves the loopback / Docker bridge.- BFF entrypoint override —
entrypoint: ["/bin/sh", "/scripts/start-frontend-with-local-ca.sh"]plususer: "0:0". The script (mounted fromhands-on-files/compose/start-frontend-with-local-ca.sh) drops the self-signed cert into/usr/local/share/ca-certificates/and runsupdate-ca-certificatesbefore launching the BFF, so it trustshttps://auth.localtest.me/.well-known/openid-configuration/jwkswhen validating JWTs. Without it the JWKS fetch fails and JWT validation throws under concurrent requests — manifesting asAn item with the same key has already been added. Key: TraceIdentifieronJwtBearerOptions.TokenValidationParameters.PropertyBag. That error looks like a code bug in MibServer3; it isn't. It's a downstream symptom of "JWKS fetch failed because the cert isn't trusted". - React shell startup script —
mib-cms-frontend-reactrunsstart-react.shwhich injects a tiny inline<script>intoindex.htmlto seedlocalStorage('mib_client_id'/'mib_client_secret')before the app boots. The React shell'sshouldUseOauthFlow()only checks localStorage (not config.json) — without that seed it falls back to the legacy cookie redirect path and ends up in a/auth/loginloop. - Plugins volume —
./plugins:/app/Customlets your custom backend DLLs land in the BFF (Part 3).
The nginx.conf reverse-proxies four subdomains:
auth.localtest.me→mib-cms-auth:8601api.localtest.me→mib-cms-api:8602backend.localtest.me→mib-cms-frontend:8603frontend.localtest.me→ routes by path:/api/,/Pages/,/version→mib-cms-frontend:8603(the BFF's V2 render + legacy MVC endpoints),/app/→mib-cms-frontend-react:8080/(strips the prefix so the shell'sbase=/app/bundle resolves its assets),/custom-components/→mib-cms-custom-components:8080/(the federated remote service, prefix-stripped the same way),- everything else →
mib-cms-frontend-react:8080(SPA fallback forindex.html).
Full config: hands-on-files/compose/nginx/nginx.conf.
live-run note— nginx buffers. The OAuth state cookie set during/api/v2/menu → 401 → redirect to authis large enough to overflow nginx's default 8K header buffer on the callback. Addlarge_client_header_buffers 8 32k;and the bundledclient_header/proxy_buffersettings (already in the artifact) — otherwise the callback returns400 Request Header Or Cookie Too Large.
live-run note—/oauth/callbackrouting. That path is the React shell's responsibility (mibfrontend OAuth client), NOT the BFF's. Don't proxy/oauth/to mib-cms-frontend or the callback ends up at the wrong service. The bundled nginx only proxies/api/,/Pages/, and/versionto the BFF; everything else falls through to the React shell.
live-run note—/app/rewrite for static assets. Theghcr.io/agilecontent/mib-frontend:2.38.0image's bundle hasbase=/app/, so itsindex.htmlreferences assets as/app/assets/.... Inside that image's own nginx the files are at/app/{assets,...}(i.e. the URL prefix duplicates the disk prefix). Without rewriting, every static request 404s. The bundled outer nginx adds a dedicated location block to strip the prefix before proxying:location ^~ /app/ { proxy_pass http://mib-cms-frontend-react:8080/; # trailing slash strips /app/ }Skip this and the React shell loads only
index.htmlwith broken asset references; the browser console fills with 404s and the sidebar/page never paints.
Step 3: Generate self-signed cert + bring up SQL Server
# 3.1 Generate self-signed cert covering all four subdomains, and trust it
# in the macOS login keychain (Linux: import into your browser manually).
bash ./compose/generate-certs.sh
# 3.2 Start SQL Server.
( cd compose && docker compose up -d sqlserver )
# 3.3 Wait until it's accepting connections + create the single database.
# This is bundled as bootstrap-databases.sh.
bash ./compose/bootstrap-databases.sh
The bootstrap script waits for SQL Server to be ready, then runs:
IF DB_ID('handson_mib') IS NULL CREATE DATABASE handson_mib;
One database. Time to seed the MIB schema.
Part 2 — Base MIB migrations
The MIB platform's canonical tables — MEDIA_TYPES, ADM_FIELDS,
ADM_RELATEDS, MIB3UX_*, AUTH_*, CONTENT_CRITERIA_*, the
seeded OAuth2 clients, the default-pages catalog, the default
MIB3UX_COMPONENTS registrations — are all owned by the
MibMigrator repo.
live-run note— one database, not two. Earlier drafts of this tutorial usedhandson_mib(catalog) andhandson_cms(UI) as two separate databases, mirroring mini-agiletv's env vars. Don't do that. TheMediaiBox.Cms.FrontEnd.Database.Migrationsset inserts seed rows intoMEDIA_TYPES(default UI components reference framework mediatypes) — running them against an emptyhandson_cmsfails withInvalid object name 'MEDIA_TYPES'. Either run every canonical migration against the same database (recommended — what the verified-workingdocker-compose.ymldoes), or run the meta-model migrations against both. One database is simpler.
MibMigrator is a small .NET 8 console program that takes one
migration assembly at a time and applies its migrations against a
configured database. You run it once per assembly, in dependency
order. The migrations themselves live as embedded resources inside the
assemblies — the .dll files in
MibMigrator/MediaiBox.Tools.Migrator/bin/Release/net8.0/ are the
authoritative source.
Step 4: Build MibMigrator
git clone git@github.com:agilecontent/MibMigrator.git
cd MibMigrator
dotnet build MibMigrator.sln -c Release
After the build, MediaiBox.Tools.Migrator/bin/Release/net8.0/ will
contain mibmigrator.dll plus the seven canonical migration assemblies
listed below. They're all needed; the entry point is the same dotnet
command repeated against each.
Step 5: The run-config file
MibMigrator reads its database target from a MibDatabaseConfig.mibconfig
in the directory you pass via -configPath. Single-database setup ⇒
one config dir:
hands-on-files/migrator-config/handson-mib/.
Content:
<!-- migrator-config/handson-mib/MibDatabaseConfig.mibconfig -->
<?xml version="1.0" encoding="utf-8" ?>
<mibConfig>
<default>
<type>sql2005</type>
<server>localhost,14333</server>
<database>handson_mib</database>
<username>sa</username>
<password>Handson12!</password>
<port>1433</port>
<timeout>900</timeout>
</default>
</mibConfig>
Gotcha — port goes inside
<server>, not<port>. With SQL Server, MibMigrator connects using only the<server>value. The<port>field is read but not used in the connection string. So if you writeserver=localhost+port=14333, MibMigrator will accidentally hit port 1433 (the default SQL instance, if any). Always useserver=localhost,14333and keepport=1433as filler — that's the working combination.Gotcha —
sapassword complexity. Fresh SQL Server containers reject simple passwords.Handson12!satisfies the complexity rules; something likeiwannarockdoes not.
Step 6: Run the canonical migrations
Run MibMigrator once per migration assembly, in dependency order. The
whole sequence is bundled as a helper script —
hands-on-files/migrator-config/run-canonical-migrations.sh:
MIB_MIGRATOR_DIR=~/MibMigrator bash ./migrator-config/run-canonical-migrations.sh
Output (takes ~3 minutes):
=== Migrating handson_mib (single database for everything) ===
>>> Core (framework foundations)
Migration done!
>>> CMS API (MEDIA_TYPES / ADM_FIELDS / ADM_RELATEDS)
Migration done!
>>> CMS DataModel (instances, sources, generic seed)
Migration done!
>>> ContentCriteria (CONTENT_CRITERIA_* tables)
Migration done!
>>> Authorization (AUTH_* — OAuth2 clients, scopes, tokens)
Migration done!
>>> CMS FrontEnd UI (MIB3UX_* + default component rows)
Migration done!
>>> Default Pages (built-in admin UI pages)
Migration done!
✓ Canonical migrations applied successfully.
What this creates (verified counts on a fresh run):
| Object | Count after migrations |
|---|---|
Tables under dbo |
~140 |
Rows in MEDIA_TYPES |
130 |
Rows in MIB3UX_COMPONENTS (built-in components) |
16 |
Rows in MIB3UX_PAGES (default admin pages) |
7 |
Rows in MIB3UX_MENU (sidebar entries) |
4 |
Rows in API_CLIENTS (OAuth2 clients) |
1 — only AuthPortal |
Rows in ADM_USERS |
1 — Administrator (password = mediaibox) |
The inline expanded commands (in case you want to see exactly what the script runs):
BIN=/path/to/MibMigrator/MediaiBox.Tools.Migrator/bin/Release/net8.0
CFG=./migrator-config/handson-mib
run_migrator() {
local label="$1"; shift
echo ">>> $label"
dotnet "$BIN/mibmigrator.dll" \
"$@" \
-configPath="$CFG" \
-section=default \
-logInformation=minimal
}
# 1. Core framework (Mib_*, App_*, basic catalog support)
run_migrator "Core" \
-assembly="$BIN/MediaiBox.Core.Database.Migrations.dll" -target=HEAD
# 2. ADM_* meta-model — MEDIA_TYPES, ADM_FIELDS, ADM_RELATEDS, etc.
run_migrator "CMS API" \
-assembly="$BIN/MediaiBox.Cms.Api.Database.Migrations.dll" -target=HEAD
# 3. Generic catalog seed (instances, sources, users seed)
run_migrator "CMS DataModel" \
-assembly="$BIN/MediaiBox.Cms.DataModel.Database.Migrations.dll" -target=HEAD
# 4. CONTENT_CRITERIA_* tables — required by the ContentCriteria component
run_migrator "ContentCriteria" \
-assembly="$BIN/MediaiBox.ContentCriteria.Database.Migrations.dll" -target=HEAD
# 5. AUTH_* tables — OAuth2 clients, scopes, tokens, roles
run_migrator "Authorization" \
-assembly="$BIN/MediaiBox.Cms.Authorization.Database.Migrations.dll" -target=HEAD
# 6. MIB3UX_* tables — UI metadata (pages, templates, components, menus)
# + the default rows for every built-in component
run_migrator "CMS FrontEnd UI" \
-assembly="$BIN/MediaiBox.Cms.FrontEnd.Database.Migrations.dll" -target=HEAD
# 7. Default pages (built-in admin UI pages like Users, Sources, etc.)
run_migrator "Default Pages" \
-assembly="$BIN/MediaiBox.Cms.DefaultPages.Database.Migrations.dll" -target=HEAD
Step 7: Seed the additional OAuth clients
The canonical migrations only seed one OAuth client (AuthPortal).
The four-service stack needs three more — one per service that
authenticates against the auth server (mibapi, mibfrontend,
mibbackend). Run the bundled seed script:
bash ./compose/seed-oauth-clients.sh
It runs an idempotent INSERT ... IF NOT EXISTS against API_CLIENTS
and prints the resulting four rows.
live-run note—CONFIGURABLE_PERMISSIONS_URLis mandatory. The script also setsAPI_CLIENTS.CONFIGURABLE_PERMISSIONS_URLon themibapiandmibbackendrows. The Permission MicroService calls those URLs at startup to learn which permission keys each service exposes (mediatype keys frommibapi/configurablepermissions, menu keys frommibbackend/configurablepermissions). If the URL is empty for a given client, the MS short-circuits to "no configurable permissions" → every CanRead check returns false → every page returns a 403 ("You do not have permissions to view this content"). Use the internal Docker-network URL (http://mib-cms-api:8602/...,http://mib-cms-frontend:8603/...) rather thanhttps://api.localtest.me/...— the MS doesn't trust the self-signed cert and would fail withUntrustedRoot.
live-run note— why aren't these clients seeded by the canonical migrations? Because client IDs and secrets are deployment concerns: they vary between local dev, customer staging, and production. The framework seeds the bare minimum (AuthPortal, used by the login page); everything else is per-installation. The alternative would be hard-coding secrets in the framework migrations, which is the wrong pattern.
Step 8: Verify the schema
docker exec handson-sqlserver /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'Handson12!' -No -d handson_mib -W -h-1 -Q "
SELECT 'mediatypes='+CAST(COUNT(*) AS VARCHAR) FROM MEDIA_TYPES;
SELECT 'mib3ux_components='+CAST(COUNT(*) AS VARCHAR) FROM MIB3UX_COMPONENTS;
SELECT 'mib3ux_pages='+CAST(COUNT(*) AS VARCHAR) FROM MIB3UX_PAGES;
SELECT 'mib3ux_menu='+CAST(COUNT(*) AS VARCHAR) FROM MIB3UX_MENU;
SELECT 'api_clients='+CAST(COUNT(*) AS VARCHAR) FROM API_CLIENTS;
SELECT 'adm_users='+CAST(COUNT(*) AS VARCHAR) FROM ADM_USERS;
"
Expected output:
mediatypes=130
mib3ux_components=16
mib3ux_pages=7
mib3ux_menu=4
api_clients=4 ← AuthPortal + the three from seed-oauth-clients.sh
adm_users=1 ← Administrator (password = mediaibox)
Step 9: Custom-table migrations (movies + assets)
Now we add our tables. We use knex — JS
migrations that create the SQL tables AND insert the matching ADM rows
in one transaction. The artifacts ship a complete migrations/
directory; the steps to apply are:
cd migrations
npm install --silent knex tedious dotenv
./node_modules/.bin/knex migrate:latest --env mib # custom tables + ADM rows
./node_modules/.bin/knex migrate:latest --env cms # UI rows (pages/templates/menu)
Both env profiles point at the same database (handson_mib) — they
exist so the two phases use independent _knex_migrations ledgers.
live-run note— meta.js column names. This is the gotcha that ate the most time during verification. Themini-agiletv_helpers/meta.jsuses ADM_FIELDS column names likeTABLE_MIBINDEX,FIELDTYPE,RELATED_TABLE, etc. — but the canonical schema (what the framework migrations create) usesMEDIATYPE,TYPE,RELATEDTABLEetc. If you copy mini-agiletv's helper as-is, every insert fails withInvalid column name 'TABLE_MIBINDEX'. The bundled_helpers/meta.jsis already adjusted for the canonical schema — diff it against mini-agiletv if you want to see the column mapping. Other required fixes:ADM_RELATEDS.NAMEis NOT NULL (canonical migrations forgot to set a default), andADM_FIELDS.MINIMUM_SECURITY_LEVELis NOT NULL too.
live-run note— base ADM_FIELDS rows are required. MibApi'sFieldChecker.VerifyColumnsAsyncchecksADM_FIELDSfor rows keyedID,DATEINS, andNAMEon every mediatype, not just the physical columns. If thoseADM_FIELDSrows are missing, everyMETA/<mediatype>request fails withAdmFieldMisconfigurationException: Column 'DATEINS' was not found on Media Type X, the BFF wraps it in aBatchGetException, and the React list/edit page renderserror/500. The bundledmaybeInsertFieldshelper auto-prepends rows forID / DATEINS / NAME / OWNERso caller migrations only need to describe their custom columns — leave that prepending in place and the verification queries below will show 9 fields per mediatype (4 base
- 5 movie, 4 base + 5 asset).
The migrations (full source in hands-on-files/migrations/):
| File | Profile | Creates |
|---|---|---|
mib/001_create_handson_movies.js |
mib |
HANDSON_MOVIES table + 9 ADM_FIELDS rows |
mib/002_create_handson_assets.js |
mib |
HANDSON_ASSETS table + 9 ADM_FIELDS rows + 1 ADM_RELATEDS row linking back to movies |
cms/001_create_handson_ui_pages.js |
cms |
2 templates, 4 template-components, 2 pages, 1 menu entry |
dashboard/001_register_movie_dashboard.js |
dashboard |
Custom backend component registration. Do NOT run until Part 3 — it references the Acme.Mib.HandsOn assembly you'll build there. Running it before the DLL is in ./plugins makes the BFF fail to load the missing assembly and the edit page returns 500. |
After applying, verify:
docker exec handson-sqlserver /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'Handson12!' -No -d handson_mib -W -h-1 -Q "
SELECT 'handson_tables='+CAST(COUNT(*) AS VARCHAR) FROM sys.tables WHERE name LIKE 'HANDSON%';
SELECT 'movie_fields='+CAST(COUNT(*) AS VARCHAR) FROM ADM_FIELDS WHERE MEDIATYPE=2001;
SELECT 'asset_fields='+CAST(COUNT(*) AS VARCHAR) FROM ADM_FIELDS WHERE MEDIATYPE=2002;
SELECT 'handson_pages='+CAST(COUNT(*) AS VARCHAR) FROM MIB3UX_PAGES WHERE PAGE_KEY LIKE 'handson%';
SELECT 'handson_menu='+CAST(COUNT(*) AS VARCHAR) FROM MIB3UX_MENU WHERE MENU_KEY LIKE 'handson%';"
Expected:
handson_tables=2
movie_fields=9 ← 4 base (ID, DATEINS, NAME, OWNER) + 5 custom
asset_fields=9 ← 4 base + 5 custom (Movie, Kind, Locale, Filename, Size)
handson_pages=2
handson_menu=1
live-run note— migration order and thedashboardprofile. Run themibprofile first (creates tables + meta), thencms(creates UI rows + menu). Do NOT run thedashboardprofile here — it lives in its own folder for a reason. That migration bindsAcme.Mib.HandsOn.MovieDashboardComponentto the edit template, and the BFF tries to instantiate the component the first time someone opens the edit page. Without the DLL deployed to./plugins/(Part 3 builds and drops it), the BFF throwsCouldNotLoadComponentAssemblyExceptionand the edit page returns 500 — the React shell renders "The server is down". Wait until Part 3 has dropped the DLL, thennpx knex migrate:latest --env dashboard.
Inside the cms/ migration — UI rows (menu, pages, templates)
The tables and the meta-model from the mib/ migrations now exist,
but nothing in the admin UI points at them. The bundled
cms/001_create_handson_ui_pages.js
applied in Step 9 above writes rows into:
MIB3UX_TEMPLATES— one template per page (list, edit)MIB3UX_TEMPLATE_COMPONENTS— what components show on each templateMIB3UX_PAGES— the URLs (/handson_movies_list,/handson_movies_edit, …)MIB3UX_PAGE_COMPONENT_CONFIGURATIONS— per-component config keys (theMEDIA_TYPE,EDIT_URL, …)MIB3UX_MENU— sidebar entries
The migration body (excerpted):
// migrations/cms/001_create_handson_ui_pages.js
exports.up = async function (knex) {
const TC = (orderIdx, name, key, componentKey, templateId, parentId, viewType) => ({ /* ... */ });
// List template: just a List Component pointed at HANDSON_MOVIES
const moviesListTemplateId = await insertTemplate(knex, "handson_movies_list_template");
const moviesListTcId = await insertTemplateComponent(knex, {
templateId: moviesListTemplateId,
name: "handson_movies_list",
templateComponentKey: "handson_movies_list",
componentKey: "mib_default_listcomponent",
componentViewType: "list",
order: 1,
});
const moviesListPageId = await insertPage(knex, {
name: "Hands-On Movies List",
pageKey: "handson_movies_list",
templateId: moviesListTemplateId,
});
await insertPageComponentConfig(knex, [
[moviesListPageId, moviesListTcId, "MEDIA_TYPE", "HANDSON_MOVIES"],
// Note the `/{id}` suffix — required by the BFF's formPageKey
// regex, see the EDIT_URL live-run note below.
[moviesListPageId, moviesListTcId, "EDIT_URL", "/app/handson_movies_edit/{id}"],
[moviesListPageId, moviesListTcId, "ADD_NEW_URL", "/app/handson_movies_edit/{id}"],
]);
// Edit template: a Form Component + a Simple Related List (assets)
const moviesEditTemplateId = await insertTemplate(knex, "handson_movies_edit_template");
const moviesFormTcId = await insertTemplateComponent(knex, {
templateId: moviesEditTemplateId,
name: "handson_movies_form",
templateComponentKey: "handson_movies_form",
componentKey: "mib_default_formcomponent",
componentViewType: "form",
order: 1,
});
const moviesAssetsTcId = await insertTemplateComponent(knex, {
templateId: moviesEditTemplateId,
name: "handson_movies_assets",
templateComponentKey: "handson_movies_assets",
componentKey: "mib_default_simplerelatedlistcomponent",
componentViewType: "relatedList",
order: 2,
});
const moviesEditPageId = await insertPage(knex, {
name: "Hands-On Movies Edit",
pageKey: "handson_movies_edit",
templateId: moviesEditTemplateId,
});
await insertPageComponentConfig(knex, [
[moviesEditPageId, moviesFormTcId, "MEDIA_TYPE", "HANDSON_MOVIES"],
[moviesEditPageId, moviesAssetsTcId, "MEDIATYPE", "HANDSON_ASSETS"],
[moviesEditPageId, moviesAssetsTcId, "PARENT_MEDIATYPE","HANDSON_MOVIES"],
]);
// Sidebar menu entry
await knex("MIB3UX_MENU").insert({
OWNER: 1, NAME: "Hands-On Movies",
MENU_KEY: "handson_movies_menu",
URL: "app/handson_movies_list", // NOT "/handson_movies_list" — see notes below
ORDER: 100,
IS_REACT: 1, // mandatory — see notes below
});
};
live-run note—IS_REACT=1is mandatory. Without it, the React shell'sMenuTreeBuilderfilters the row out before rendering and the sidebar shows no Hands-On entry at all. Flip the bit on every MIB3UX_MENU row whose URL points at a React-shell page (i.e. starts with/app/).Crucially, do NOT flip
IS_REACTto 1 on the canonicalsample_root,menu_movie_content*andmenu_vod_artwork_assetsrows. Their URLs are~/Display/...— legacy MIB3 WebForms paths that resolve tohttps://backend.localtest.me/Display/...afterMakeRootedUrl. Showing them in the React sidebar sends users to the cert-untrusted BFF host with no matching page on the React shell. The bundledseed-permissions.shdeliberately omits these keys from the grant list for the same reason — your sidebar should contain only Hands-On Movies after the base setup.
live-run note—EDIT_URL/ADD_NEW_URLmust end with/<page-key>/{id}(literal{id}). That{id}token has two jobs and both are mandatory for the list page to be usable:
- The BFF's
ListWorkflow.GetFormPageKeyruns the regex/(?<pageKey>[^/]+)/{id}$againstRawEditUrlto derive the schema'sformPageKeyfield. Without{id}the regex fails →formPageKeyis empty in the schema response → the React shell's list widget renders without the "+ New" button in the toolbar AND without the per-row edit pencil in the action column. The list looks complete but you can't open or create anything from it.ListComponentBaselater substitutes{id}with0for theCreateUrl(the "+ New" button target) and with each row's actual id for the per-row edit link. So/app/handson_movies_edit/0opens the blank Create form and/app/handson_movies_edit/1re-loads movie #1.The bundled
cms/001_create_handson_ui_pages.jsseeds bothEDIT_URLandADD_NEW_URLas/app/handson_movies_edit/{id}. Keep the literal{id}at the end — quoting/encoding it is fine (the value is opaque to the BFF until the regex substitution step).
live-run note—/app/is the React shell's base path. Two moving parts collaborate to put/app/in the URL bar (so the menu renders ashref="/app/handson_movies_list"and the browser showshttps://frontend.localtest.me/app/...on every page):
React shell
BASE_PATH=/app/, set in docker-compose under themib-cms-frontend-reactservice. The shell pipes this into React Router'screateBrowserRouter({ basename: basePath }), andformatAppPathkeys off the same value to know not to strip/app/from emitted hrefs. WithBASE_PATH=/, every menu href collapses to a bare/handson_movies_listand the user never sees/app/in the URL bar.
MIB3UX_MENU.URLprefix/app/<page-key>. The BFF'sMakeRootedUrltrims a leading/, sees theapp/prefix, and returns the URL unchanged as/app/<page-key>(a same-origin relative URL). The React shell'sformatAppPaththen hands that same URL to React Router, which dispatches to the<page-key>-keyed route under its/app/basename.The same
/app/<...>prefix MUST be used everywhere a URL is stored:MIB3UX_MENU.URL,MIB3UX_PAGE_COMPONENT_CONFIGURATIONSEDIT_URL / ADD_NEW_URL / LIST_URL, and the OAuthAPI_CLIENTS.REDIRECT_URI(set tohttps://frontend.localtest.me/app/oauth/callback).URLs without the
/app/prefix get rootUrl-prepended into absolutehttps://backend.localtest.me/<path>links byMakeRootedUrl→ they open the BFF directly, hit the self-signed-cert error, and break SPA navigation.
The full file with all the insert* helpers is at
hands-on-files/migrations/cms/001_create_handson_ui_pages.js.
Two important details:
COMPONENT_VIEW_TYPEis the column that overrides the React type-key resolution. We set it to'list'/'form'/'relatedList'so the React shell dispatches to the built-in widgets — even though the BFF class name (ListComponentetc.) would technically resolve to the same key by the default class-name rule, being explicit here pays off when you start mixing custom backends later.COMPONENT_KEYinMIB3UX_TEMPLATE_COMPONENTS.COMPONENT_IDpoints at the row inMIB3UX_COMPONENTSfor the stock component. We don't need a custom row inMIB3UX_COMPONENTSyet — the base migrations already inserted 16 default rows (mib_default_listcomponent,mib_default_formcomponent,mib_default_simplerelatedlistcomponent, …) covering every built-in component. Step 14 below adds the first custom row.
The cms/001 migration was already applied above as part of Step 9
(./node_modules/.bin/knex migrate:latest --env cms); no extra
command needed here.
Step 9.5: Seed AUTH_PERMISSIONS for the Hands-On menu
Even after Step 9 inserts the MIB3UX_MENU row, the React shell still
won't render Hands-On Movies on a fresh login. The BFF's
MenuTreeBuilder filters every MIB3UX_MENU row through a
case-sensitive AUTH_PERMISSIONS.PERMISSION_KEY == MIB3UX_MENU.MENU_KEY
lookup — if no row matches, the menu is silently dropped. The
canonical migrations don't seed any rows, so even the bundled
sample_* menus are filtered out on a fresh stack.
Run the bundled script:
bash ./compose/seed-permissions.sh
docker compose restart mib-cms-permission # invalidates the in-memory cache
The script grants the user's group (Administrators) Boolean=true on
the Hands-On menu keys, scoped to the mibbackend OAuth client:
| Key | Source | Why |
|---|---|---|
handson_movies_menu |
this tutorial's cms/001_create_handson_ui_pages.js |
The Hands-On entry. |
It explicitly does not seed the canonical sample_root /
menu_movie_content* / menu_vod_artwork_assets keys — those rows
are legacy WebForms entries pointing at ~/Display/... URLs on
backend.localtest.me, with no matching React-shell page. The bundled
script also DELETEs any leftover grants on those keys, so re-running
it cleans up any earlier draft seedings.
The script also inserts the AUTH_USER_GROUP row mapping user_id=1
(Administrator) to group_id=1. The whole script is idempotent.
live-run note—api_client_idmust bemibbackend, notmibfrontend. The BFF asks the Permission MicroService for permissions scoped to its own OAuth client_id —mibbackend, set byMIBAUTHORIZATIONCLIENTCONFIG_DEFAULT_CLIENTIDin docker-compose.yml. Seeding rows againstmibfrontend(the React shell's client) leaves the menu tree silently empty. The bundled script resolves the right client_id at run time (SELECT ID FROM API_CLIENTS WHERE OAUTH_CLIENT_ID='mibbackend').
live-run note—PERMISSION_KEYmust equalMENU_KEYexactly. Case-sensitiveFirstOrDefaultlookup.handson_moviesvshandson_movies_menuwon't match.
live-run note— why isn't this baked into the migrations? Same reason as the OAuth clients: which keys apply to which group is a deployment concern. Production installs typically have a custom RBAC matrix; the framework can't know it ahead of time.
Step 10: Boot the stack
Now everything is in place:
docker compose up -d
docker compose logs -f mib-cms-frontend # watch for "Application started"
live-run note— SQL Server healthcheck. The compose file pinssqlserverwith ahealthcheck:runningSELECT 1and the downstream services usedepends_on: { condition: service_healthy }. Without it the .NET services race SQL Server's TLS pre-login on Apple Silicon Rosetta and crash withUnknown error 203mid-handshake. Also pinned:MIBDATABASECONFIG_DEFAULT_TRUSTSERVERCERTIFICATE=trueandMIBDATABASECONFIG_DEFAULT_ENCRYPT=falseon every .NET service — the in-network DB hop doesn't need TLS, and disabling it dodges the Rosetta SSL flake entirely. The BFF additionally hasrestart: unless-stoppedso an unlucky cold-start still self-heals.
Once you see Now listening on: http://[::]:8603, open
https://frontend.localtest.me (accept the self-signed certificate),
log in with admin / admin (the seeded credentials), and the
sidebar should show Hands-On Movies. Click it → the List Component
renders an empty list with an "+ New" button at the top right. Click
"+ New" → the Form Component renders the title/description/release
year fields, and an empty Assets related-list at the bottom.
Expected UI structure (the parts to look for):
- Sidebar shows Hands-On Movies as a new entry at the bottom.
- List page (
/handson_movies_list) renders with columns Title, Release Year, Rating, Featured, Date Inserted.- "+ New" button top-right; ID search top-left; pagination bottom-right.
- Form page (
/handson_movies_edit) renders Title (required), Description, Release Year, Rating, Featured fields. Save/Cancel toolbar.- Below the form, the Assets related-list with "Add Related" → "Add New".
Add a row. Click "+ New", type a title and description, click Save. The list now has one row, and editing it shows the Assets related-list ready for child rows. No custom code yet — the database rows alone got you to a fully-working CRUD UI.
This is the baseline. Everything from here is extending it.
Part 3 — A custom C# backend component
Time to add a custom widget to the Movie edit page — a dashboard panel that pulls a small summary from the BFF (counts of assets by kind, years since release, "featured" badge) and emits a custom schema
- data shape.
Step 11: Set up the customisation project
Create a new directory handson-backend/:
mkdir handson-backend && cd handson-backend
dotnet new classlib -n Acme.Mib.HandsOn -f net8.0
The csproj needs references to the BFF contract DLLs. Because they're not on NuGet, we extract them once from the running container:
mkdir -p /tmp/mib-cms-frontend-dlls
docker run --rm --entrypoint sh \
873339144623.dkr.ecr.us-east-1.amazonaws.com/agile/mib-cms-frontend:${MIB_TAG} \
-c "tar -C /app -cf - MediaiBox.Cms.FrontEnd.Model.dll \
MediaiBox.Cms.FrontEnd.Model.Mvc.dll \
MediaiBox.Cms.Api.Client.Model.dll \
MediaiBox.Core.dll" \
> /tmp/mib-cms-frontend-dlls/refs.tar
tar -C /tmp/mib-cms-frontend-dlls -xf /tmp/mib-cms-frontend-dlls/refs.tar
Then Acme.Mib.HandsOn.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Acme.Mib.HandsOn</AssemblyName>
<RootNamespace>Acme.Mib.HandsOn</RootNamespace>
<!-- Don't copy referenced framework DLLs into our output (we'd shadow
the FrontEnd Server's own copies and break loading). -->
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- These four DLLs are extracted once from the running FrontEnd Server
image (see Step 11 above). Adjust HintPath if you put them
elsewhere. -->
<ItemGroup>
<Reference Include="MediaiBox.Cms.FrontEnd.Model">
<HintPath>/tmp/mib-cms-frontend-dlls/MediaiBox.Cms.FrontEnd.Model.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="MediaiBox.Cms.FrontEnd.Model.Mvc">
<HintPath>/tmp/mib-cms-frontend-dlls/MediaiBox.Cms.FrontEnd.Model.Mvc.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="MediaiBox.Cms.Api.Client.Model">
<HintPath>/tmp/mib-cms-frontend-dlls/MediaiBox.Cms.Api.Client.Model.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="MediaiBox.Core">
<HintPath>/tmp/mib-cms-frontend-dlls/MediaiBox.Core.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies> is
the critical line — without it, your build would ship a second copy of
MediaiBox.Core.dll (and every transitive dependency) into your
output, which the FrontEnd Server would refuse to load (assembly
version conflict). The per-Reference <Private>false</Private> is a
belt-and-braces backup that does the same thing for each explicit
<Reference>.
Step 12: Write the component
A custom backend component is one C# class. Save it at
handson-backend/MovieDashboardComponent.cs — this is the file shipped
verbatim at
hands-on-files/backend/MovieDashboardComponent.cs:
using MediaiBox.Cms.Api.Client.Model;
using MediaiBox.Cms.FrontEnd.Model.Dao.Item;
using MediaiBox.Cms.FrontEnd.Model.Mvc.UI.Component;
using MediaiBox.Cms.FrontEnd.Model.UI;
using MediaiBox.Cms.FrontEnd.Model.UI.Component;
using MediaiBox.Cms.FrontEnd.Model.UI.Context;
using MediaiBox.Cms.FrontEnd.Model.Workflow;
using Microsoft.AspNetCore.Http;
namespace Acme.Mib.HandsOn;
public sealed class MovieDashboardSchema : IComponentSchema
{
public string MediaType { get; set; } = "HANDSON_MOVIES";
public bool ShowFeaturedBadge { get; set; } = true;
}
public sealed class MovieDashboardData
{
public int MovieId { get; set; }
public string Title { get; set; } = "";
public bool Featured { get; set; }
public int AssetsTotal { get; set; }
public int AssetsVideoCount { get; set; }
public int AssetsSubtitleCount { get; set; }
public int AssetsThumbnailCount{ get; set; }
public int? YearsSinceRelease { get; set; }
}
public sealed class MovieDashboardComponent
: IRenderableComponentV2<ComponentConfiguration, MovieDashboardData, MovieDashboardSchema>,
IComponentTypeProvider
{
private IMibApiClientLibrary? _apiClient;
public ContextViewData Context { get; set; } = new();
public ComponentConfiguration Configuration { get; set; } = new();
public ComponentDisplayMode DisplayMode => ComponentDisplayMode.Edit;
public string GetComponentType() => "acme_movie_dashboard";
public Task Initialize(
IMibApiClientLibrary api,
IPublicWorkflowFactory _,
IHttpContextAccessor __)
{
_apiClient = api;
return Task.CompletedTask;
}
public ComponentPermission GetPermission() => new()
{
Read = PermissionType.Allow,
Write = PermissionType.DontCare,
Create = PermissionType.DontCare,
Delete = PermissionType.DontCare,
};
// Required by IComponent<TConfig>. The DisplayWorkflow calls this
// via reflection BEFORE MapData/MapSchema run, so the dashboard
// wouldn't load if you omit it — even though GetPermission() looks
// like it does the same job.
public ComponentPermission GetPermissions(List<ResponseItemContent> responses) => GetPermission();
public Task<MovieDashboardSchema> MapSchema(object _, CancellationToken __) =>
Task.FromResult(new MovieDashboardSchema
{
MediaType = "HANDSON_MOVIES",
ShowFeaturedBadge = true,
});
public Task<MovieDashboardData> MapData(object _, CancellationToken ct)
{
// No selected id (list / new-record mode) — return an empty
// payload, NOT null. Returning null is treated as a render
// failure by the framework.
if (Context.IDs is null || !Context.IDs.Any())
return Task.FromResult(new MovieDashboardData { MovieId = 0, Title = "" });
var id = Context.IDs.First();
// Demo stub: deterministic mock counts keyed off the movie id
// so the federated remote can demo the render + event surface
// without depending on actual asset rows in the DB. Replace
// with real IMibApiClientLibrary calls once you have data —
// e.g. _apiClient!.GetItemByIdAsync<HandsonMoviesEntity>(
// "HANDSON_MOVIES", id, ct) plus a SearchAsync for assets.
return Task.FromResult(new MovieDashboardData
{
MovieId = id,
Title = $"Movie #{id}",
Featured = id % 2 == 1,
AssetsTotal = 3,
AssetsVideoCount = 1,
AssetsSubtitleCount = 1,
AssetsThumbnailCount = 1,
YearsSinceRelease = 5,
});
}
}
What this code does, step by step:
IRenderableComponentV2<TConfig, TData, TSchema>is the contract.TConfig=ComponentConfiguration(no custom config keys — we don't need any here);TDataandTSchemaare our two DTOs.IComponentTypeProvider.GetComponentType()returns"acme_movie_dashboard". That's the string the React shell will look up in itscustomComponentsmap. We chose a unique namespaced string instead of relying on the class-name fallback.GetPermission()ANDGetPermissions(List<ResponseItemContent>)are both required. The first comes fromIRenderableComponentV2; the second comes fromIComponent<TConfig>and theDisplayWorkflowcalls it via reflection beforeMapData/MapSchemarun. Omit either and the component silently fails to load.MapSchemareturns the stable, per-mediatype shape. Cheap to compute, called once per render.MapDataruns per request. The tutorial ships a stub that returns deterministic mock data so you can verify the wiring end-to-end before introducing MibApi calls. A real implementation would inject DTOs (e.g.HandsonMoviesEntity,HandsonAssetsEntity) matching your tables and call_apiClient.GetItemByIdAsync<T>("HANDSON_MOVIES", id, ct)/_apiClient.SearchAsync<T>("HANDSON_ASSETS", new SearchFilter { Where = $"MOVIE_ID={id}" }, ct).Context.IDsis the selected mediatype id. In edit mode it has exactly one entry; in list / new-record mode it's empty. We handle both — list mode returns an empty payload so the React side renders a friendly "select a movie" state.- Return value
nullis illegal. The framework treats anullreturn fromMapDataorMapSchemaas a failure and drops the component from the response with a[Render-ComponentFailure]log line. Always return an empty payload instead.
Step 13: Build and drop the DLL
dotnet build -c Release -o ./out
# Copy the resulting DLL into the docker-compose plugins folder
mkdir -p ../plugins
cp ./out/Acme.Mib.HandsOn.dll ../plugins/
# Restart just the BFF — the DLL is loaded on demand on the first
# request, but a restart clears its internal assembly cache.
docker compose restart mib-cms-frontend
The BFF picks up Acme.Mib.HandsOn.dll from /app/Custom/ because we
mounted ./plugins there in the compose file. The first request that
references this assembly through a MIB3UX_COMPONENTS row will trigger
Assembly.LoadFrom(...) on it.
Step 14: Register the component in MIB3UX_COMPONENTS
The bundled
migrations/dashboard/001_register_movie_dashboard.js
registers the new assembly with the framework. The migration lives in
its own dashboard profile (separate from mib and cms) so the
base-setup steps from Part 2 don't touch it — without the DLL deployed
the BFF would 500 on every edit-page render.
Apply it after dropping the DLL:
( cd migrations && ./node_modules/.bin/knex migrate:latest --env dashboard )
The migration does three things:
// migrations/dashboard/001_register_movie_dashboard.js
exports.up = async function (knex) {
// 1. Register the custom assembly + class
const [componentId] = await knex("MIB3UX_COMPONENTS").insert({
OWNER: 1,
NAME: "Acme Movie Dashboard",
COMPONENT_KEY: "acme_movie_dashboard",
ASSEMBLY_NAME: "Acme.Mib.HandsOn",
CLASS_NAME: "Acme.Mib.HandsOn.MovieDashboardComponent",
}).returning("ID");
// 2. Bind it to the existing movie edit template, at the top
const tcId = await insertTemplateComponent(knex, {
templateId: <handson_movies_edit_template id>,
name: "handson_movies_dashboard",
templateComponentKey: "handson_movies_dashboard",
componentKey: "acme_movie_dashboard",
componentViewType: null, // ← let GetComponentType() decide
order: 0, // ← render before the Form
});
// 3. Pass the mediatype as configuration
await insertPageComponentConfig(knex, [
[<page id>, tcId, "MEDIA_TYPE", "HANDSON_MOVIES"],
]);
};
Reload the edit page. The component is in the response now — but
with no React widget registered yet, the shell renders a <Missing>
placeholder and logs a console warning
Component type 'acme_movie_dashboard' is not registered. That's the
hand-off cue: we need a React widget under that key.
Part 4 — A federated React remote
Step 15: Scaffold the remote
A federated remote is a normal Vite + React app with the
@originjs/vite-plugin-federation plugin. Scaffold it in a sibling
directory:
mkdir handson-frontend && cd handson-frontend
npm init -y
# Runtime deps — bundled into the remote (or de-duped with the shell
# via the `shared` Module Federation config).
npm install --save \
react@^18 react-dom@^18 \
react-i18next@^12 i18next@^22 \
styled-components@^6
# Dev-time deps — Vite, federation plugin, TypeScript, @types/*.
npm install --save-dev \
vite@^5 @vitejs/plugin-react@^4 \
@originjs/vite-plugin-federation@^1.3 \
typescript@^5 @types/react @types/react-dom
# Types-only deps — @agilecontent/ui and antd are provided at runtime
# by the federation host, so they go in devDependencies. TypeScript
# can resolve their types during the build; the bundler does NOT ship
# them into your remote's output.
npm install --save-dev \
@agilecontent/ui@^2 \
@agilecontent/mib-modules \
@agilecontent/mib-api-connector \
antd@^5
The vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
base: "/custom-components/",
plugins: [
react(),
federation({
name: "customComponents",
filename: "remoteEntry.js",
exposes: { "./all": "./src/main.tsx" },
shared: [
"react",
"react-dom",
"react-i18next",
"@agilecontent/ui",
"@agilecontent/mib-modules",
"@agilecontent/mib-api-connector",
"styled-components",
],
}),
],
build: { target: "esnext" },
});
Three critical things:
base: "/custom-components/"— the path where nginx serves thedist/output. Must match the path in the shell'sCUSTOM_COMPONENTS_URLenv var.filename: "remoteEntry.js"— the federation manifest file.shared— the dependencies that are loaded once (from the shell), not rebundled into your remote. Always include@agilecontent/uihere — see UI Primitives for why importing antd directly breaks the shell's modals.
Step 16: The dispatch map (src/main.tsx)
// src/main.tsx
import MovieDashboard from "./components/MovieDashboard";
import ProbeAssetAction from "./components/ProbeAssetAction";
import useTitleAutoSlug from "./hooks/useTitleAutoSlug";
// 1. The component registry — the key MUST match what the BFF emits
// (see Step 12: `GetComponentType() => "acme_movie_dashboard"`).
export const components = {
acme_movie_dashboard: MovieDashboard,
};
// 2. Decorators — per-row Item Action on the assets related-list.
// Step 18 explains why this is the only decorator slot the current
// shell consumes without a BFF patch.
export const decorators = {
relatedList: {
itemActions: { edit: ProbeAssetAction },
},
};
// 3. useFormChange — top-level hook the shell fires whenever a Form
// Component mounts (Step 18.2 — useTitleAutoSlug).
export const useFormChange = useTitleAutoSlug;
// 4. Translations — optional. Merged into the shell's i18n dictionary.
export const translations = {};
export const handlers = () => [];
export const skeletons = {};
export default components;
The shell merges these named exports across all three federation slots
(customComponents, customComponents2, customComponents3). If two
remotes define the same key, the second one wins.
live-run note— which decorator slots the shell ACTUALLY reads. Only threedecorators.*paths are wired into the v6.0.97 shell:
decorators.relatedList.itemActions[key]— gated byrow.permissions[key]. The BFF emits the discrete set{edit, remove, preview, download, quickEdit}per row, so keying the actioneditlets it ride an existing per-row permission with no migration work.decorators.relatedList.headerActions[key]— gated byschema.configuration[key]being truthy. The BFF'sRelatedListSchemaConfigurationis currently a closed C# class that only emits seven hard-coded keys (addNew,selectExisting,sortable,limit,deleteAll,editable,refreshable), so an arbitraryrefreshAllAssets = truerow inMIB3UX_PAGE_COMPONENT_CONFIGURATIONSnever reaches the React schema. A reference componentRefreshAllAssetsAction.tsxis bundled for when that closed class is patched to flow extra keys through (the React-sideConfigurationtype already accepts[key: string]: unknown).decorators.list.selectionHeaderActions[key]— same closed-class problem onListSchemaConfiguration.
decorators.list.headerActions/decorators.list.itemActionsare NOT consumed (nouseDecorators('list')reads them anywhere in v6.0.97 — the type definition only declaresselectionHeaderActions). Earlier drafts of this tutorial wiredRefreshAllMoviesActionandRefreshOneMovieActioninto those slots; they're shipped as reference source underhands-on-files/frontend/src/components/but are intentionally NOT imported in the federation entry above.
Step 17: The custom React widget
src/components/MovieDashboard/index.tsx:
import { Card, Tag, Row, Col, Skeleton } from "@agilecontent/ui";
import { CalendarOutlined, StarFilled } from "@agilecontent/ui/icons";
import type { MibComponentProps } from "@agilecontent/mib-api-connector";
type Schema = {
mediaType: string;
showFeaturedBadge: boolean;
};
type Data = {
movieId: number;
title: string;
featured: boolean;
assetsTotal: number;
assetsVideoCount: number;
assetsSubtitleCount: number;
assetsThumbnailCount: number;
yearsSinceRelease: number | null;
};
const MovieDashboard = (props: MibComponentProps<Schema, Data>) => {
const { schema, data } = props;
if (!data?.movieId) {
return <Card>Select a movie to see its dashboard.</Card>;
}
return (
<Card title={data.title}>
<Row gutter={16}>
<Col span={6}>
<Stat label="Assets total" value={data.assetsTotal} />
</Col>
<Col span={6}>
<Stat label="Video files" value={data.assetsVideoCount} />
</Col>
<Col span={6}>
<Stat label="Subtitle files" value={data.assetsSubtitleCount} />
</Col>
<Col span={6}>
<Stat label="Thumbnails" value={data.assetsThumbnailCount} />
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col span={12}>
<CalendarOutlined />{" "}
{data.yearsSinceRelease ?? "—"} years since release
</Col>
<Col span={12}>
{schema.showFeaturedBadge && data.featured && (
<Tag color="gold"><StarFilled /> Featured</Tag>
)}
</Col>
</Row>
</Card>
);
};
const Stat = ({ label, value }: { label: string; value: number }) => (
<div>
<div style={{ fontSize: 24, fontWeight: 600 }}>{value}</div>
<div style={{ color: "var(--color-title)" }}>{label}</div>
</div>
);
export default MovieDashboard;
What's happening:
- Props are exactly
MibComponentProps<S, D>as documented in Component Authoring. - All UI primitives (
Card,Tag,Row,Col,Skeleton) come from@agilecontent/ui— never fromantddirectly. See UI Primitives for the full surface. - The
<Stat>helper uses the shell's CSS variables for theming (--color-title) instead of hard-coding colors. A customer rebrand applies automatically. - The widget reads
schema.showFeaturedBadgeto gate optional UI — that's how you let the C# config control React behavior.
Step 18: Related-List Item Action — Probe asset
The bundled remote wires a single decorator surface: a per-row
Probe button on the "Hands-On Assets" related-list inside the
movie edit page. Source at
hands-on-files/frontend/src/components/ProbeAssetAction.tsx.
import React, { useState } from "react";
import { TextButton, Tooltip, successMessage, errorMessage } from "@agilecontent/ui";
import { ExperimentOutlined } from "@agilecontent/ui/icons";
import { publishAssetEvent } from "../hooks/useAssetEvents";
type Row = { id: string | number; permissions?: Record<string, boolean> };
type Props = { row: Row; events?: { refresh?: () => void } };
export default function ProbeAssetAction({ row, events }: Props) {
const [busy, setBusy] = useState(false);
const onClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setBusy(true);
try {
// Demo stub: a real backend round-trip would look like
// const { data } = await window.cms.instance.post(
// `/api/v2/handson/assets/${row.id}/probe`,
// );
// but the rest of the wiring (toast, refresh, event bus) is
// identical — see the inline comment in ProbeAssetAction.tsx
// for why this demo fakes the round-trip on the client.
await new Promise((r) => setTimeout(r, 250));
const idNum = typeof row.id === "number" ? row.id : parseInt(String(row.id), 10) || 0;
const duration = 60 + (idNum % 240);
const size = 256_000 + idNum * 17_000;
successMessage(
`Probed asset #${row.id}: duration ${duration}s, ${Math.round(size / 1024)} KB`,
);
events?.refresh?.();
publishAssetEvent("probed", { id: row.id, duration, size });
} catch (e: any) {
errorMessage(`Probe failed: ${e.message}`);
} finally {
setBusy(false);
}
};
return (
<Tooltip title="Probe metadata">
<TextButton htmlType="button" loading={busy} icon={<ExperimentOutlined />} onClick={onClick} />
</Tooltip>
);
}
Wired in the federation entry (Step 16) as
decorators.relatedList.itemActions = { edit: ProbeAssetAction }. The
shell renders one of these buttons per row that satisfies its
permission gate:
Object.entries(itemActions)
.filter(([key]) => row.permissions[key])
.map(([, component]) => component);
The federation key (edit) is what gets looked up on row.permissions.
The BFF already emits {edit, remove, preview, download, quickEdit}
per row, so edit means "render on every row the user can edit" with
zero migration work. A real customisation that needs a custom
permission key would teach the permissions-service / BFF schema to
emit it.
live-run note—htmlType="button"is mandatory. The action lives inside the host edit-page's<form>. Antd buttons default totype="submit"; without an explicithtmlType="button"the click bubbles to the form's submit handler and POSTs the whole movie record to the page URL — the action's own handler never runs.e.preventDefault() + e.stopPropagation()kill any leftover form-submit semantics for good measure. The v6.0.97 shell does NOT attach a nativeonClickto each<tr>(the id column renders an<a target="_blank">link instead), so there's no row-level bubble to fight.
live-run note— Bearer auth viawindow.cms.instancefor real backend calls. When you replace the demo stub above with a real POST, usewindow.cms.instance.post(url)(axios with the shell's Bearer-token interceptor) — notfetch(). Barefetch()lands at the BFF withoutAuthorization: Bearer <jwt>and gets 302-redirected to/login, which the shell's axios response interceptor turns into a redirect-to-login navigation. The shell attaches the instance towindow.cms.instancefor exactly this reason.
live-run note— custom[Route("api/v2/...")]controllers and the BFF auth pipeline. A plainControllerBasesubclass decorated with[Authorize](or one whose action triggers the BFF'sWorkflowFactoryMiddleware, which is gated by an[Authorize]attribute being present) gets rejected with 401 — theMibSecurityTokenValidatorround-trips the Bearer JWT to the AuthorizationServer and ends in aNotAuthorizedExceptionthat the middleware turns into a 401. The shell's axios response interceptor reads that 401 and bounces the user to/login, which looks like "the action navigates away" instead of running.Two ways to write a working controller:
[AllowAnonymous]on plainControllerBase— opts the action out of the[Authorize]gate AND theWorkflowFactoryMiddleware.UseWhenbranch (the branch only fires when[Authorize]metadata is present on the endpoint). The trade-off: the Bearer-token identity is NOT available inside the action body. Fine for stubs / demo endpoints. The bundledbackend/Controllers/AssetsController.csandMoviesController.csuse this pattern.- Inherit from
ApiBaseController— a real customer endpoint that needsUser.Identity.Nameshould inheritApiBaseController. It carries the right[Authorize(AuthenticationSchemes = Constants.API_SCHEME)]attribute AND the BFF DI plumbing the workflow-factory pipeline wants.The bundled
ProbeAssetAction.tsxdoesn't actually POST — it stubs the round-trip on the client (setTimeout) so the rest of the wiring (toast,events.refresh, event-bus pulse on the dashboard) stays observable without pullingApiBaseControllerinto the build dependencies. Replace the stub withwindow.cms.instance.post(...)to the bundled[AllowAnonymous]endpoint when you want the real round-trip.
Step 18.1: Cross-component events (publish / subscribe bus)
A decorator only sees its host's events via props (refresh,
delete, onChange, …). To let components OUTSIDE the host's prop
chain react to a CRUD action — say, surfacing a transient indicator
in the Movie Dashboard when an asset is probed — the bundled remote
ships a tiny module-scoped EventTarget bus:
// hands-on-files/frontend/src/hooks/useAssetEvents.ts
const bus = new EventTarget();
export function publishAssetEvent(name, detail) {
bus.dispatchEvent(new CustomEvent(name, { detail }));
}
export function useAssetEvent(name, handler) {
useEffect(() => {
const wrapped = (e) => handler(e.detail);
bus.addEventListener(name, wrapped);
return () => bus.removeEventListener(name, wrapped);
}, [name, handler]);
}
ProbeAssetAction calls publishAssetEvent('probed', { id, duration, size }) after its work completes. MovieDashboard subscribes with
useAssetEvent('probed', …) and flashes a tag in the card's extra
slot ("Asset probe just now"). The dashboard is mounted ABOVE the
related-list in the DOM and is never given the related-list's
events prop directly — the bus is the contract that ties them
together. A second subscription on 'reindexed' is wired the same
way for when a future header action (or any other surface) wants to
broadcast a re-scan.
In production code you'd reach for @tanstack/query /
SWR / a redux slice instead of a bare EventTarget; the bundled
helper is intentionally a few lines so the demo is easy to lift into
a new remote.
Step 18.2: useFormChange — the form-level hook
The shell calls a top-level useFormChange(pageKey, FormInstance, setComponents) for every page that mounts a Form Component. The
bundled
hands-on-files/frontend/src/hooks/useTitleAutoSlug.ts
demonstrates it by deriving the NAME field (the row's slug) from
the user-visible TITLE field as the operator types:
// Must match the TEMPLATE_COMPONENT_KEY of the Form Component on
// the movies edit page (see migrations/cms/001_create_handson_ui_pages.js).
const FORM_KEY = "handson_movies_form";
export default function useTitleAutoSlug(pageKey, form, _setComponents) {
const enabled = useMemo(
() => pageKey === "handson_movies_edit",
[pageKey],
);
const lastAutoSlug = useRef(null);
useEffect(() => {
if (!enabled || !form) return;
let prevTitle = form.getFieldValue([FORM_KEY, "TITLE"]) ?? "";
const tick = () => {
const title = form.getFieldValue([FORM_KEY, "TITLE"]) ?? "";
if (title === prevTitle) return;
prevTitle = title;
const currentName = form.getFieldValue([FORM_KEY, "NAME"]) ?? "";
const nextSlug = slug(title);
if (currentName && currentName !== lastAutoSlug.current) return;
form.setFieldsValue({ [FORM_KEY]: { NAME: nextSlug } });
lastAutoSlug.current = nextSlug;
};
const id = window.setInterval(tick, 300);
return () => window.clearInterval(id);
}, [enabled, form]);
}
Wired by the federation entry:
export const useFormChange = useTitleAutoSlug;
Notes worth knowing:
- Page-key gating. The hook runs on every page that has a Form
Component. Gate by
pageKey(here=== "handson_movies_edit") so unrelated pages see a no-op — without the gate, the hook would happily rewrite theNAMEfield on any page that happens to have aTITLE. - Operator-override detection. Once the operator types into
Namethemselves,lastAutoSlug.current !== currentNameand the hook stops rewriting. The hands-off behavior is what makes auto-slugging acceptable in a Form Component — otherwise it'd fight the operator. - Polling vs
Form.useWatch. The bundled hook pollsform.getFieldValueevery 300 ms because the antd FormInstance is the explicit contract the shell hands over. For higher-traffic fields useForm.useWatchfrom antd 5 instead — both are valid; the shell handler tick-rates throttle either way.
live-run note— antd field paths are namespaced by template-component key. The shell wraps every Form Component's fields under its template-component key inside the page-level antd FormInstance, so the live shape ofform.getFieldsValue()is{ handson_movies_form: { TITLE, NAME, DESCRIPTION, ... }, handson_movies_assets: { items: [...] }, handson_movies_dashboard: { ... }, // empty for our custom widget }Reading
form.getFieldValue("TITLE")(top-level) returnsundefined; the correct call is the array formform.getFieldValue(["handson_movies_form", "TITLE"]). Same for writes —setFieldsValue({ handson_movies_form: { NAME: slug } }). If your hook seems to "do nothing", chase this first.
Step 19: Build the remote, drop the dist
The remote is its own service — mib-cms-custom-components in
the bundled docker-compose.yml. We build a multi-stage Docker
image from
hands-on-files/frontend/Dockerfile
and the outer nginx proxies /custom-components/ straight to it.
That mirrors a real customer deployment, where the custom-components
bundle is published as its own container image and the gateway
fronts it the same way.
# 1. Build the federated-remote image. NPM_AUTH_TOKEN is a GitHub
# PAT with `read:packages` scope — the @agilecontent/* packages
# live on https://npm.pkg.github.com/.
( cd hands-on-files/frontend && \
docker build \
--build-arg NPM_AUTH_TOKEN=$GH_PAT \
-t handson-custom-components:local . )
# 2. (Re)create the service.
( cd hands-on-files/compose && \
docker compose up -d --force-recreate mib-cms-custom-components && \
docker compose restart nginx )
# 3. Sanity-check the routes.
curl -ks -o /dev/null -w "remoteEntry: %{http_code}\n" \
https://frontend.localtest.me/custom-components/assets/remoteEntry.js
curl -ks -o /dev/null -w "healthcheck: %{http_code}\n" \
https://frontend.localtest.me/custom-components/
The Dockerfile is multi-stage:
| Stage | Image | What it does |
|---|---|---|
build |
node:20-alpine |
writes .npmrc pointing at GitHub Packages, runs npm install, then npx vite build producing dist/ with base=/custom-components/ baked into every asset URL |
runtime |
nginx:1.27-alpine |
drops a tiny nginx.conf that serves dist/ on port 8080, sets permissive CORS (Module Federation needs it), gives remoteEntry.js short cache life so redeploys are picked up fast, and keeps hashed asset chunks immutable |
The outer nginx (the gateway) routes the prefix to the service:
location ^~ /custom-components/ {
proxy_pass http://mib-cms-custom-components:8080/; # trailing / strips the prefix
}
live-run note— stubindex.htmlfor the rollup graph.@originjs/vite-plugin-federation1.3.x still expects anindex.htmlnext tovite.config.tseven when the build only produces a remote bundle. Without one, rollup errors withCould not resolve entry module index.html. The bundled artifact ships a one-line stubhands-on-files/frontend/index.html; it is never served at runtime — the shell loads/custom-components/assets/remoteEntry.jsdirectly via Module Federation.
live-run note—${__federation_expose_./all}placeholder appearing literally inremoteEntry.js. If you customisebuild.rollupOptions.inputto bypassindex.html, the plugin stops substituting its expose-file placeholder and the shell errors at runtime withTypeError: Failed to resolve module specifier '${__federation_expose_./all}'. Keep the default rollup input (the stubindex.htmlabove) and let the plugin pick the entry — the placeholder then resolves to a real asset filename like__federation_expose_All-<hash>.js.
live-run note—base: "/custom-components/"in vite.config.ts. The federated chunks insideremoteEntry.jsreference each other by absolute URL, so they must know the public path at build time. Keep it aligned with the gateway'slocation ^~ /custom-components/prefix — if you change the public route to/widgets/, you must also flipbasein vite.config.ts AND updateCUSTOM_COMPONENTS_URLon the React shell.
For local dev with hot-reload, run Vite's preview server
(npm run dev) and point CUSTOM_COMPONENTS_URL at
http://localhost:4000/assets/remoteEntry.js instead of the
gateway-served path. The preview server doesn't serve https, so
for full SSO testing use the production build.
Reload https://frontend.localtest.me/app/handson_movies_edit/1
(hard reload to clear the federation cache). On the movie edit page
you should now see:
- Movie Dashboard widget rendered as a tab — four stat boxes
(Assets total / Videos / Subtitles / Thumbnails), a "years since
release" line with a calendar icon, and (if the movie's
FEATUREDis true) a gold "Featured" tag with a star. - Hands-On Assets related-list — on each row, an
"experiment" / flask icon button (the
ProbeAssetActionitem action). Clicking it pops a toast (Probed asset #1: duration 61s, 267 KB) and the Movie Dashboard's card-header flashes a blue tag "Asset probe just now" — that's the event-bus subscription firing (Step 18.1). - TITLE → NAME slugging — clear the
NAMEfield, then type intoTITLE. TheNAMEfield re-derives as a kebab-case slug within ~300 ms. Hand-editNAMEand the auto-rewrite stops (operator- override detection from Step 18.2).
You now have the full federation surface wired: the BFF emits a
custom component type (acme_movie_dashboard), the React shell loads
the corresponding widget from
https://frontend.localtest.me/custom-components/assets/remoteEntry.js,
and the widget renders against the props returned by your C# MapData.
Part 5 — Connecting it all and verifying
What just happened, end to end
sequenceDiagram
autonumber
participant Browser
participant Nginx
participant Shell as React shell
participant BFF
participant Dll as Acme.Mib.HandsOn.dll
participant Db as SQL Server
Browser->>Nginx: GET /handson_movies_edit/42
Nginx->>Shell: HTML
Shell->>BFF: GET /api/v2/display/handson_movies_edit/42
BFF->>Db: SELECT FROM MIB3UX_TEMPLATE_COMPONENTS<br/>WHERE template_id = ...
BFF->>BFF: For each row: load assembly + class
BFF->>Dll: Assembly.LoadFrom('/app/Custom/Acme.Mib.HandsOn.dll')
BFF->>Dll: new MovieDashboardComponent()
BFF->>Dll: MapSchema() / MapData()
Dll->>Db: SELECT FROM HANDSON_ASSETS WHERE MOVIE_ID=42
Dll-->>BFF: { schema, data }
BFF->>BFF: ResolveComponentType → IComponentTypeProvider<br/>→ "acme_movie_dashboard"
BFF-->>Shell: { components: [<br/> {type:"acme_movie_dashboard", schema, data},<br/> {type:"form", schema, data},<br/> {type:"relatedList", schema, data}<br/>] }
Shell->>Nginx: GET /custom-components/assets/remoteEntry.js
Nginx-->>Shell: federation manifest
Shell->>Nginx: GET /custom-components/assets/MovieDashboard.[hash].js
Shell->>Shell: customComponents["acme_movie_dashboard"]<br/>→ MovieDashboard
Shell->>Browser: render(<MovieDashboard schema={...} data={...}/>)
Inspecting the render
If something looks wrong, three places to look:
BFF logs —
docker compose logs mib-cms-frontend | grep -E "RenderV2-Stats|Render-ComponentFailure". The[RenderV2-Stats]line shows each component's outcome (Success/Errored/SkippedHidden). AnErroredis paired with a[Render-ComponentFailure]line carrying the full stack.Network panel in DevTools — open the
GET /api/v2/display/<page>/<id>response. Look for yourTemplateComponentKeyincomponents[]. If it's missing, the component isErrored(see logs) orSkippedHidden.React console — the shell logs
Component type 'X' is not registeredwhen a type-string from the BFF doesn't match any key incoreComponentsorcustomComponents. That's the symptom of:- a typo between the C#
GetComponentType()and the Reactcomponents: { ... }key, or - the federated remote failing to load (the request to
remoteEntry.js404'd or CORS-failed).
- a typo between the C#
What you can change without rebuilding
The whole point of splitting the work across three surfaces is that they have independent deploy cycles:
| Change | What you redeploy |
|---|---|
Add a new field to HANDSON_MOVIES |
Just the SQL + ADM_FIELDS row (a migration). The List and Form widgets pick it up on next render. |
| Add a new business rule on save | An IPersistenceComponentPlugin (not covered here) — drop one more DLL. |
| Change the dashboard's React UI | Only the federated remote build. Customer can hot-deploy the new remoteEntry.js with zero downtime on anything else. |
| Add a new C# component class | New DLL + one migration row (the MIB3UX_COMPONENTS registration). |
| Move the dashboard to a different page | One UPDATE on MIB3UX_TEMPLATE_COMPONENTS. |
| Rebrand colors | Replace one CSS file mounted into the React shell container. See Theming. |
That's the whole point of the layering. Every layer has its own extension surface, and the layers don't have to redeploy in sync.
Troubleshooting
"Component type 'X' is not registered" in the React console
The BFF emitted a component the shell can't find. Check:
Acme.Mib.HandsOn.MovieDashboardComponent.GetComponentType()returns exactly the same string as the key incustomComponentsinsrc/main.tsx. They're case-sensitive.- The federated remote actually loaded. In the Network tab, search for
remoteEntry.js— should be 200 OK. If it's a 404, your nginx isn't serving/custom-components/. - The remote loaded but the federation merge failed. Open the Console
and look for "Failed to fetch dynamically imported module" — that's a
CORS issue between the shell origin and the remote origin (they
should be same-origin or have proper
Access-Control-Allow-Origin).
"CouldNotLoadComponentAssemblyException" in BFF logs
The BFF saw a MIB3UX_COMPONENTS row pointing at an assembly it can't
find. Check:
- The DLL is in
/app/Custom/inside the container.docker compose exec mib-cms-frontend ls /app/Custom. If empty, your volume mount is wrong. - The DLL's transitive dependencies are also present in
/app/. Specifically, anything not set to<Private>false</Private>in the csproj will be copied into your output and conflict with the framework's copies. Re-check your csproj. - The class name in
MIB3UX_COMPONENTS.CLASS_NAMEmatches the full namespace + class name of your component, including the assembly's default namespace.Acme.Mib.HandsOn.MovieDashboardComponent, notMovieDashboardComponent.
<Missing> placeholder rendered with no error logs
The BFF emitted the component fine, but the React shell can't find the widget. Same diagnosis as "Component type X is not registered" above — this is the friendly placeholder the shell renders in production mode.
MapData returned an empty dashboard but the BFF logs show no errors
MapData ran successfully but returned a payload your widget treats
as "empty". Confirm:
Context.IDsactually contains an id. In edit mode this is the URL path id; in list mode it's empty.- Your
_apiClient.GetItemByIdAsync(...)didn't return null because of a permissions issue. The user logged into the shell must have read permission onHANDSON_MOVIES. CheckMIB_PERMISSION_*rows for the admin user / mediatype.
"Error: undefined is not iterable" in the React console
The schema or data shape returned by the BFF doesn't match what the
React widget expects. JSON casing. Remember: .NET 8's
System.Text.Json serializes MyProperty to "myProperty" by
default (camelCase). Your React Data type's property names must use
camelCase, or you must opt out of the default JSON naming in your C#
DTO with [JsonPropertyName(...)] attributes.
backend.localtest.me returns 502 / BFF logs stop after "Overriding HTTP_PORTS"
You're on Apple Silicon. The .NET BFF runs x86_64 under Rosetta, and
Microsoft.Data.SqlClient's TLS pre-login handshake intermittently
fails — sometimes the process crashes (Exited (139)), sometimes it
just blocks forever before Kestrel binds.
The bundled docker-compose.yml fixes this three ways. If a docker
compose update or merge ever loses any of them, re-add:
healthcheck:on thesqlserverservice (SELECT 1via sqlcmd)depends_on: { sqlserver: { condition: service_healthy } }on every .NET service. Without it the BFF starts before SQL Server is accepting connections and crashes mid-handshake withUnknown error 203.
MIBDATABASECONFIG_DEFAULT_TRUSTSERVERCERTIFICATE=trueplusMIBDATABASECONFIG_DEFAULT_ENCRYPT=falseon every .NET service. These flow into the SqlClient connection string asTrustServerCertificate=true;Encrypt=False;, skipping the brittle TLS handshake entirely. The DB hop is loopback/Docker-bridge — no security loss.restart: unless-stoppedonmib-cms-frontend. If an unlucky cold-start still hits the flake, the container auto-recovers in seconds.
If you've already hit the hang, the recovery is docker compose down && docker compose up -d (full down, not just restart — restart can leave
the broken process in a futex deadlock that no signal frees).
Hands-On menu doesn't appear in the sidebar after login
The migrations created the MIB3UX_MENU row, but no AUTH_PERMISSIONS
row grants any group the right to see it. The Permission MicroService
silently filters unknown menu keys out of the menu API response, so
the sidebar renders without the entry.
Fix:
bash ./compose/seed-permissions.sh
docker compose restart mib-cms-permission # required — cache invalidation
Symptoms when this is missing: the login lands on the WELCOME page, the top-bar avatar dropdown works, but the left sidebar shows only the built-in System / Configuration items — no Hands-On Movies. Re-run the seed script and restart the permission service to fix it.
Beyond the tutorial
You've now done every layer that a real customer deployment touches. Each is much deeper than this tutorial covers — here are the natural next steps:
| Topic | Why | Doc |
|---|---|---|
| Plugins (custom business rules, validators, image resolvers) | Hook into save / delete / image flows without overloading a component | Plugin: Custom Business Rule, Plugin: Custom Validation |
| Custom auth flows (SSO, TOTP, custom IdP) | Replace the OAuth2 client side with a customer-specific flow | Authorization Server Configuration |
| Multi-instance setup | Same code, multiple tenants in one DB | Pages — Multi-instance configuration |
Performance — [RenderV2-Stats] reading |
Diagnose slow pages by per-component timing | System Overview — Per-component evaluation |
| Replacing the React shell's chrome (logo, menu order) | Customer branding | Theming and Menus |
| Audit trail | Track per-field changes | Audit Trail |
See also
- Component Authoring — full reference for the contracts and shapes used here
- UI Primitives —
@agilecontent/ui— every component you can import in your federated widget - Pages & Templates — the deeper
MIB3UX_*schema - Local Development — alternate flows for iterating on a customisation without docker-compose
- Built-in Components — what the stock widgets give you that you don't need to re-build
- System Overview — RenderV2 — the full request pipeline behind the dashboard panel above
mini-agiletv— a much bigger working reference built on the same pattern (video importer + DRM + several custom widgets + a Temporal-based workflow engine)