Table of Contents

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 note call-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.me with 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 widgetMovieDashboard — 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.me subdomains resolve to 127.0.0.1 via 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 with ArgumentNullException: Value cannot be null. (Parameter 'httpClient') in MibSecurityTokenValidator..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 built handson-mib-cms-frontend-react:local from MibFrontEnd source 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, missing base=/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 databasehandson_mib. See the live-run note in Part 2 for why we abandoned the two-database split.
  • mib-cms-frontend:6.0.97 (and the same tag for mib-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 with base=/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 with Unknown error 203 (a Rosetta-on-Apple-Silicon flake). The BFF also has restart: unless-stopped so an unlucky cold-start self-heals.
  • TrustServerCertificate=true / Encrypt=false — set on every .NET service via MIBDATABASECONFIG_DEFAULT_TRUSTSERVERCERTIFICATE and MIBDATABASECONFIG_DEFAULT_ENCRYPT. Skips the brittle TLS pre-login negotiation entirely. Safe here: the connection never leaves the loopback / Docker bridge.
  • BFF entrypoint overrideentrypoint: ["/bin/sh", "/scripts/start-frontend-with-local-ca.sh"] plus user: "0:0". The script (mounted from hands-on-files/compose/start-frontend-with-local-ca.sh) drops the self-signed cert into /usr/local/share/ca-certificates/ and runs update-ca-certificates before launching the BFF, so it trusts https://auth.localtest.me/.well-known/openid-configuration/jwks when validating JWTs. Without it the JWKS fetch fails and JWT validation throws under concurrent requests — manifesting as An item with the same key has already been added. Key: TraceIdentifier on JwtBearerOptions.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 scriptmib-cms-frontend-react runs start-react.sh which injects a tiny inline <script> into index.html to seed localStorage('mib_client_id'/'mib_client_secret') before the app boots. The React shell's shouldUseOauthFlow() only checks localStorage (not config.json) — without that seed it falls back to the legacy cookie redirect path and ends up in a /auth/login loop.
  • Plugins volume./plugins:/app/Custom lets your custom backend DLLs land in the BFF (Part 3).

The nginx.conf reverse-proxies four subdomains:

  • auth.localtest.memib-cms-auth:8601
  • api.localtest.memib-cms-api:8602
  • backend.localtest.memib-cms-frontend:8603
  • frontend.localtest.me → routes by path:
    • /api/, /Pages/, /versionmib-cms-frontend:8603 (the BFF's V2 render + legacy MVC endpoints),
    • /app/mib-cms-frontend-react:8080/ (strips the prefix so the shell's base=/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 for index.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 auth is large enough to overflow nginx's default 8K header buffer on the callback. Add large_client_header_buffers 8 32k; and the bundled client_header / proxy_buffer settings (already in the artifact) — otherwise the callback returns 400 Request Header Or Cookie Too Large.

live-run note/oauth/callback routing. 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 /version to the BFF; everything else falls through to the React shell.

live-run note/app/ rewrite for static assets. The ghcr.io/agilecontent/mib-frontend:2.38.0 image's bundle has base=/app/, so its index.html references 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.html with 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 used handson_mib (catalog) and handson_cms (UI) as two separate databases, mirroring mini-agiletv's env vars. Don't do that. The MediaiBox.Cms.FrontEnd.Database.Migrations set inserts seed rows into MEDIA_TYPES (default UI components reference framework mediatypes) — running them against an empty handson_cms fails with Invalid object name 'MEDIA_TYPES'. Either run every canonical migration against the same database (recommended — what the verified-working docker-compose.yml does), 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 write server=localhost + port=14333, MibMigrator will accidentally hit port 1433 (the default SQL instance, if any). Always use server=localhost,14333 and keep port=1433 as filler — that's the working combination.

Gotcha — sa password complexity. Fresh SQL Server containers reject simple passwords. Handson12! satisfies the complexity rules; something like iwannarock does 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 noteCONFIGURABLE_PERMISSIONS_URL is mandatory. The script also sets API_CLIENTS.CONFIGURABLE_PERMISSIONS_URL on the mibapi and mibbackend rows. The Permission MicroService calls those URLs at startup to learn which permission keys each service exposes (mediatype keys from mibapi/configurablepermissions, menu keys from mibbackend/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 than https://api.localtest.me/... — the MS doesn't trust the self-signed cert and would fail with UntrustedRoot.

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. The mini-agiletv _helpers/meta.js uses ADM_FIELDS column names like TABLE_MIBINDEX, FIELDTYPE, RELATED_TABLE, etc. — but the canonical schema (what the framework migrations create) uses MEDIATYPE, TYPE, RELATEDTABLE etc. If you copy mini-agiletv's helper as-is, every insert fails with Invalid column name 'TABLE_MIBINDEX'. The bundled _helpers/meta.js is already adjusted for the canonical schema — diff it against mini-agiletv if you want to see the column mapping. Other required fixes: ADM_RELATEDS.NAME is NOT NULL (canonical migrations forgot to set a default), and ADM_FIELDS.MINIMUM_SECURITY_LEVEL is NOT NULL too.

live-run note — base ADM_FIELDS rows are required. MibApi's FieldChecker.VerifyColumnsAsync checks ADM_FIELDS for rows keyed ID, DATEINS, and NAME on every mediatype, not just the physical columns. If those ADM_FIELDS rows are missing, every META/<mediatype> request fails with AdmFieldMisconfigurationException: Column 'DATEINS' was not found on Media Type X, the BFF wraps it in a BatchGetException, and the React list/edit page renders error/500. The bundled maybeInsertFields helper auto-prepends rows for ID / DATEINS / NAME / OWNER so 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 the dashboard profile. Run the mib profile first (creates tables + meta), then cms (creates UI rows + menu). Do NOT run the dashboard profile here — it lives in its own folder for a reason. That migration binds Acme.Mib.HandsOn.MovieDashboardComponent to 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 throws CouldNotLoadComponentAssemblyException and the edit page returns 500 — the React shell renders "The server is down". Wait until Part 3 has dropped the DLL, then npx 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 template
  • MIB3UX_PAGES — the URLs (/handson_movies_list, /handson_movies_edit, …)
  • MIB3UX_PAGE_COMPONENT_CONFIGURATIONS — per-component config keys (the MEDIA_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 noteIS_REACT=1 is mandatory. Without it, the React shell's MenuTreeBuilder filters 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_REACT to 1 on the canonical sample_root, menu_movie_content* and menu_vod_artwork_assets rows. Their URLs are ~/Display/... — legacy MIB3 WebForms paths that resolve to https://backend.localtest.me/Display/... after MakeRootedUrl. Showing them in the React sidebar sends users to the cert-untrusted BFF host with no matching page on the React shell. The bundled seed-permissions.sh deliberately 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 noteEDIT_URL / ADD_NEW_URL must end with /<page-key>/{id} (literal {id}). That {id} token has two jobs and both are mandatory for the list page to be usable:

  1. The BFF's ListWorkflow.GetFormPageKey runs the regex /(?<pageKey>[^/]+)/{id}$ against RawEditUrl to derive the schema's formPageKey field. Without {id} the regex fails → formPageKey is 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.
  2. ListComponentBase later substitutes {id} with 0 for the CreateUrl (the "+ New" button target) and with each row's actual id for the per-row edit link. So /app/handson_movies_edit/0 opens the blank Create form and /app/handson_movies_edit/1 re-loads movie #1.

The bundled cms/001_create_handson_ui_pages.js seeds both EDIT_URL and ADD_NEW_URL as /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 as href="/app/handson_movies_list" and the browser shows https://frontend.localtest.me/app/... on every page):

  1. React shell BASE_PATH=/app/, set in docker-compose under the mib-cms-frontend-react service. The shell pipes this into React Router's createBrowserRouter({ basename: basePath }), and formatAppPath keys off the same value to know not to strip /app/ from emitted hrefs. With BASE_PATH=/, every menu href collapses to a bare /handson_movies_list and the user never sees /app/ in the URL bar.

  2. MIB3UX_MENU.URL prefix /app/<page-key>. The BFF's MakeRootedUrl trims a leading /, sees the app/ prefix, and returns the URL unchanged as /app/<page-key> (a same-origin relative URL). The React shell's formatAppPath then 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_CONFIGURATIONS EDIT_URL / ADD_NEW_URL / LIST_URL, and the OAuth API_CLIENTS.REDIRECT_URI (set to https://frontend.localtest.me/app/oauth/callback).

URLs without the /app/ prefix get rootUrl-prepended into absolute https://backend.localtest.me/<path> links by MakeRootedUrl → 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_TYPE is 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 (ListComponent etc.) 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_KEY in MIB3UX_TEMPLATE_COMPONENTS.COMPONENT_ID points at the row in MIB3UX_COMPONENTS for the stock component. We don't need a custom row in MIB3UX_COMPONENTS yet — 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 noteapi_client_id must be mibbackend, not mibfrontend. The BFF asks the Permission MicroService for permissions scoped to its own OAuth client_id — mibbackend, set by MIBAUTHORIZATIONCLIENTCONFIG_DEFAULT_CLIENTID in docker-compose.yml. Seeding rows against mibfrontend (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 notePERMISSION_KEY must equal MENU_KEY exactly. Case-sensitive FirstOrDefault lookup. handson_movies vs handson_movies_menu won'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 pins sqlserver with a healthcheck: running SELECT 1 and the downstream services use depends_on: { condition: service_healthy }. Without it the .NET services race SQL Server's TLS pre-login on Apple Silicon Rosetta and crash with Unknown error 203 mid-handshake. Also pinned: MIBDATABASECONFIG_DEFAULT_TRUSTSERVERCERTIFICATE=true and MIBDATABASECONFIG_DEFAULT_ENCRYPT=false on every .NET service — the in-network DB hop doesn't need TLS, and disabling it dodges the Rosetta SSL flake entirely. The BFF additionally has restart: unless-stopped so 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:

  1. IRenderableComponentV2<TConfig, TData, TSchema> is the contract. TConfig = ComponentConfiguration (no custom config keys — we don't need any here); TData and TSchema are our two DTOs.
  2. IComponentTypeProvider.GetComponentType() returns "acme_movie_dashboard". That's the string the React shell will look up in its customComponents map. We chose a unique namespaced string instead of relying on the class-name fallback.
  3. GetPermission() AND GetPermissions(List<ResponseItemContent>) are both required. The first comes from IRenderableComponentV2; the second comes from IComponent<TConfig> and the DisplayWorkflow calls it via reflection before MapData / MapSchema run. Omit either and the component silently fails to load.
  4. MapSchema returns the stable, per-mediatype shape. Cheap to compute, called once per render.
  5. MapData runs 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).
  6. Context.IDs is 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.
  7. Return value null is illegal. The framework treats a null return from MapData or MapSchema as 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 the dist/ output. Must match the path in the shell's CUSTOM_COMPONENTS_URL env 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/ui here — 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 three decorators.* paths are wired into the v6.0.97 shell:

  • decorators.relatedList.itemActions[key] — gated by row.permissions[key]. The BFF emits the discrete set {edit, remove, preview, download, quickEdit} per row, so keying the action edit lets it ride an existing per-row permission with no migration work.
  • decorators.relatedList.headerActions[key] — gated by schema.configuration[key] being truthy. The BFF's RelatedListSchemaConfiguration is currently a closed C# class that only emits seven hard-coded keys (addNew, selectExisting, sortable, limit, deleteAll, editable, refreshable), so an arbitrary refreshAllAssets = true row in MIB3UX_PAGE_COMPONENT_CONFIGURATIONS never reaches the React schema. A reference component RefreshAllAssetsAction.tsx is bundled for when that closed class is patched to flow extra keys through (the React-side Configuration type already accepts [key: string]: unknown).
  • decorators.list.selectionHeaderActions[key] — same closed-class problem on ListSchemaConfiguration.

decorators.list.headerActions / decorators.list.itemActions are NOT consumed (no useDecorators('list') reads them anywhere in v6.0.97 — the type definition only declares selectionHeaderActions). Earlier drafts of this tutorial wired RefreshAllMoviesAction and RefreshOneMovieAction into those slots; they're shipped as reference source under hands-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 from antd directly. 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.showFeaturedBadge to gate optional UI — that's how you let the C# config control React behavior.

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 notehtmlType="button" is mandatory. The action lives inside the host edit-page's <form>. Antd buttons default to type="submit"; without an explicit htmlType="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 native onClick to 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 via window.cms.instance for real backend calls. When you replace the demo stub above with a real POST, use window.cms.instance.post(url) (axios with the shell's Bearer-token interceptor) — not fetch(). Bare fetch() lands at the BFF without Authorization: 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 to window.cms.instance for exactly this reason.

live-run note — custom [Route("api/v2/...")] controllers and the BFF auth pipeline. A plain ControllerBase subclass decorated with [Authorize] (or one whose action triggers the BFF's WorkflowFactoryMiddleware, which is gated by an [Authorize] attribute being present) gets rejected with 401 — the MibSecurityTokenValidator round-trips the Bearer JWT to the AuthorizationServer and ends in a NotAuthorizedException that 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:

  1. [AllowAnonymous] on plain ControllerBase — opts the action out of the [Authorize] gate AND the WorkflowFactoryMiddleware.UseWhen branch (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 bundled backend/Controllers/AssetsController.cs and MoviesController.cs use this pattern.
  2. Inherit from ApiBaseController — a real customer endpoint that needs User.Identity.Name should inherit ApiBaseController. It carries the right [Authorize(AuthenticationSchemes = Constants.API_SCHEME)] attribute AND the BFF DI plumbing the workflow-factory pipeline wants.

The bundled ProbeAssetAction.tsx doesn'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 pulling ApiBaseController into the build dependencies. Replace the stub with window.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 the NAME field on any page that happens to have a TITLE.
  • Operator-override detection. Once the operator types into Name themselves, lastAutoSlug.current !== currentName and 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 polls form.getFieldValue every 300 ms because the antd FormInstance is the explicit contract the shell hands over. For higher-traffic fields use Form.useWatch from 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 of form.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) returns undefined; the correct call is the array form form.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 servicemib-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 — stub index.html for the rollup graph. @originjs/vite-plugin-federation 1.3.x still expects an index.html next to vite.config.ts even when the build only produces a remote bundle. Without one, rollup errors with Could not resolve entry module index.html. The bundled artifact ships a one-line stub hands-on-files/frontend/index.html; it is never served at runtime — the shell loads /custom-components/assets/remoteEntry.js directly via Module Federation.

live-run note${__federation_expose_./all} placeholder appearing literally in remoteEntry.js. If you customise build.rollupOptions.input to bypass index.html, the plugin stops substituting its expose-file placeholder and the shell errors at runtime with TypeError: Failed to resolve module specifier '${__federation_expose_./all}'. Keep the default rollup input (the stub index.html above) and let the plugin pick the entry — the placeholder then resolves to a real asset filename like __federation_expose_All-<hash>.js.

live-run notebase: "/custom-components/" in vite.config.ts. The federated chunks inside remoteEntry.js reference each other by absolute URL, so they must know the public path at build time. Keep it aligned with the gateway's location ^~ /custom-components/ prefix — if you change the public route to /widgets/, you must also flip base in vite.config.ts AND update CUSTOM_COMPONENTS_URL on 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:

  1. 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 FEATURED is true) a gold "Featured" tag with a star.
  2. Hands-On Assets related-list — on each row, an "experiment" / flask icon button (the ProbeAssetAction item 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).
  3. TITLE → NAME slugging — clear the NAME field, then type into TITLE. The NAME field re-derives as a kebab-case slug within ~300 ms. Hand-edit NAME and 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:

  1. BFF logsdocker compose logs mib-cms-frontend | grep -E "RenderV2-Stats|Render-ComponentFailure". The [RenderV2-Stats] line shows each component's outcome (Success / Errored / SkippedHidden). An Errored is paired with a [Render-ComponentFailure] line carrying the full stack.

  2. Network panel in DevTools — open the GET /api/v2/display/<page>/<id> response. Look for your TemplateComponentKey in components[]. If it's missing, the component is Errored (see logs) or SkippedHidden.

  3. React console — the shell logs Component type 'X' is not registered when a type-string from the BFF doesn't match any key in coreComponents or customComponents. That's the symptom of:

    • a typo between the C# GetComponentType() and the React components: { ... } key, or
    • the federated remote failing to load (the request to remoteEntry.js 404'd or CORS-failed).

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 in customComponents in src/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_NAME matches the full namespace + class name of your component, including the assembly's default namespace. Acme.Mib.HandsOn.MovieDashboardComponent, not MovieDashboardComponent.

<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.IDs actually 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 on HANDSON_MOVIES. Check MIB_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:

  1. healthcheck: on the sqlserver service (SELECT 1 via 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 with Unknown error 203.
  2. MIBDATABASECONFIG_DEFAULT_TRUSTSERVERCERTIFICATE=true plus MIBDATABASECONFIG_DEFAULT_ENCRYPT=false on every .NET service. These flow into the SqlClient connection string as TrustServerCertificate=true;Encrypt=False;, skipping the brittle TLS handshake entirely. The DB hop is loopback/Docker-bridge — no security loss.
  3. restart: unless-stopped on mib-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