Table of Contents

Component Authoring

This page is the entry point for adding a custom component to a Media-iBox CMS page.

In a hurry? If you'd rather follow a step-by-step walk-through — from docker compose up to a working custom widget on a custom mediatype — jump straight to the Hands-On Tutorial. This reference doc is the deeper "why each piece works the way it does"; the tutorial is the "type these commands in order" version. A "component" is one panel of a page (a list, a form, a custom widget). Each component has up to two halves:

  1. A C# class in MibServer3 (the BFF) that handles the server side — permissions, configuration, and the {schema, data} payload returned to the React shell.
  2. A React component in a federated remote that renders the panel using that {schema, data} payload.

The two halves are joined by a string — the component-type key (list, form, my_custom_widget, …). The BFF picks which C# class to instantiate from the database; the React shell picks which React component to render from the type-key it receives in the response.

You do not need both halves to be custom. The matrix is small but each quadrant has different mechanics — see What you can mix and match below before you start coding.

Related docs.
System Overview — RenderV2 — what the BFF does on a render request.
Pages & Templates — how a page is laid out and how its components are bound to a template.
6.0/React Components — per-type Schema/Data shapes for stock components.
Legacy Component Model — the pre-V2 (MVC) component contract that some legacy customer code still uses.

What you can mix and match

Both halves of a component are independent. Four combinations are possible:

Backend (C#) Frontend (React) When to use it
Core Core The default. The customer plugs a built-in component (e.g. list of a built-in mediatype) onto a page through database rows only. No code involved.
Custom Core Server-side logic is custom — different query, computed columns, validation — but the rendered widget is the stock list/form/etc. Your C# class emits the same Schema/Data shape as the core component so the shell can dispatch to the built-in widget.
Core Custom Backend is one of the framework's built-in components, but you want a different React UI for it. Less common; typically used to swap the React renderer for a built-in component on a specific page.
Custom Custom Both sides are yours. The BFF instantiates your C# class, your class emits a {schema, data} payload using your own shapes, and your federated React widget renders it.

The component-type string is what controls dispatch on each side:

  • BFF side — picks which C# class through the MIB3UX_COMPONENTS / MIB3UX_TEMPLATE_COMPONENTS rows (ASSEMBLY_NAME + CLASS_NAME). The chosen class is loaded at request time by reflection.
  • React side — picks which React component through the type string in the response. Resolution order: MIB3UX_TEMPLATE_COMPONENTS.COMPONENT_VIEW_TYPE override → IComponentTypeProvider.GetComponentType() if the C# class implements it → the class name lower-cased with Component stripped (so ListComponentlist).

That last rule is critical for the Custom backend + Core React quadrant: either name your C# class ListComponent (so the default rule resolves to list) or set COMPONENT_VIEW_TYPE = list on the template-component row. Otherwise the shell will look for a React widget under your custom component's name and fail to find one.

Big picture

sequenceDiagram
    participant Shell as React shell<br/>(apps/cms)
    participant BFF as MibServer3 (BFF)
    participant DB as MIB3UX_* tables
    participant Cmp as Your C# class<br/>(loaded from BIN)
    participant Widget as Your React widget<br/>(federated remote)

    Shell->>BFF: GET /api/v2/display/<pageKey>/<id>
    BFF->>DB: lookup template → components → assembly+class
    BFF->>Cmp: reflectively load DLL, instantiate class
    BFF->>Cmp: project per-instance config onto IComponentConfiguration
    BFF->>Cmp: ShouldHideComponent? (optional)
    BFF->>Cmp: MapSchema(viewData, ct)
    BFF->>Cmp: MapData(viewData, ct)
    Cmp-->>BFF: { schema, data }
    BFF->>BFF: ResolveComponentType(class, COMPONENT_VIEW_TYPE, IComponentTypeProvider)
    BFF-->>Shell: { components: [{ type, schema, data }, ...] }
    Shell->>Widget: lookup type in coreComponents / customComponents
    Shell->>Widget: render(<Widget schema={...} data={...} {...} />)

Three things to notice:

  1. The BFF doesn't scan assemblies for an attribute at startup. It reads MIB3UX_COMPONENTS from the database; the rows tell it which DLL and class to load on demand. That's why there is no [ComponentType] attribute in the codebase — the wiring is in the database, not in code.
  2. The C# class is loaded via Assembly.LoadFrom against the /app/ working directory. Custom customer DLLs go flat next to MediaiBox.Cms.FrontEnd.Server.dll. There is no plugins folder, no isolated AssemblyLoadContext. The loader is MibReflection.GetAssemblyFromBin (in MediaiBox.Core.Reflection.MibReflection).
  3. The component-type string is derived at request time, not declared by attribute. See DisplayWorkflow.ResolveComponentType (precedence: COMPONENT_VIEW_TYPE column → IComponentTypeProvider → class name minus Component).

The C# side

NuGet packages

Reference the two BFF contract packages from your customisation csproj:

<PackageReference Include="MediaiBox.Cms.FrontEnd.Model.Mvc" Version="..." />
<PackageReference Include="MediaiBox.Cms.FrontEnd.Model"     Version="..." />

The first carries IRenderableComponentV2, IComponent<T>, IHideableComponent, IRefreshableComponentV2, IComponentTypeProvider. The second carries IComponentConfiguration, IComponentSchema, ContextViewData, ComponentContext, etc.

Set <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> on the csproj — MSBuild will then put your DLL plus any transitive references into the publish output, which is what dotnet publish rolls into the Docker image.

The V2 contracts

Components are plain C# classes that implement one or more of these interfaces. None of them require an attribute; the BFF discovers your class through the database row.

// Required for any visible component.
public interface IRenderableComponentV2<TConfiguration, TData, TSchema>
    : IComponent<TConfiguration>
    where TConfiguration : IComponentConfiguration
    where TSchema        : IComponentSchema
{
    ComponentPermission GetPermission();
    Task<TData>   MapData  (object viewData, CancellationToken cancellationToken);
    Task<TSchema> MapSchema(object viewData, CancellationToken cancellationToken);
}

// Optional. Gates rendering on a per-request boolean.
public interface IHideableComponent
{
    Task<bool> ShouldHideComponent(ComponentContext context, CancellationToken ct);
}

// Optional. Lets the React side refresh just this widget without a full reload.
public interface IRefreshableComponentV2<TConfiguration, TViewData>
    : IComponent<TConfiguration>
    where TConfiguration : IComponentConfiguration
{
    Task<TViewData> MapRefreshData(DisplayComponentViewData viewData, CancellationToken ct);
}

// Optional. Lets one C# class be dispatched to different React widgets at runtime.
public interface IComponentTypeProvider
{
    string GetComponentType();
}

TConfiguration, TData, TSchema are your own DTOs — they're the contract you negotiate with the React side. TSchema must implement IComponentSchema (the framework marker interface).

A minimal V2 component

using MediaiBox.Cms.FrontEnd.Model.Mvc.UI.Component;
using MediaiBox.Cms.FrontEnd.Model.UI.Component;

namespace Acme.Mib.MyWidget;

public class MyWidgetComponent
    : IRenderableComponentV2<MyWidgetConfiguration, MyWidgetData, MyWidgetSchema>,
      IHideableComponent
{
    public ContextViewData         Context        { get; set; }
    public MyWidgetConfiguration   Configuration  { get; set; }
    public ComponentDisplayMode    DisplayMode    => ComponentDisplayMode.Edit;

    public ComponentPermission GetPermission() => new()
    {
        Create = PermissionType.DontCare,
        Read   = PermissionType.Allow,
        Write  = PermissionType.DontCare,
        Delete = PermissionType.DontCare,
    };

    public Task<MyWidgetSchema> MapSchema(object viewData, CancellationToken ct)
    {
        return Task.FromResult(new MyWidgetSchema
        {
            MediaType  = Configuration.MediaType,
            AllowEdit  = Configuration.AllowEdit,
            Columns    = MyWidgetColumns.Default,
        });
    }

    public async Task<MyWidgetData> MapData(object viewData, CancellationToken ct)
    {
        // Note: returning null from MapData is treated as a render FAILURE,
        // not as "no data". Return an empty payload instead.
        if (Context.IDs is null || !Context.IDs.Any())
            return new MyWidgetData { Items = Array.Empty<MyWidgetItem>() };

        var items = await FetchItemsAsync(Context.IDs, ct);
        return new MyWidgetData { Items = items };
    }

    public Task<bool> ShouldHideComponent(ComponentContext ctx, CancellationToken ct)
    {
        // Example: hide for users without write permission on the mediatype.
        var canWrite = ctx.MediaTypeInformation?.Permissions.HasFlag(Permission.CanWrite) ?? false;
        return Task.FromResult(!canWrite);
    }

    public Task Initialize(...)  => Task.CompletedTask;  // required by IComponent
}

Context and Configuration are settable properties — the BFF injects them via reflection after constructing the instance. See Reflection-based instantiation below for how it works.

MibComponentWrapper — the GVP convenience pattern

GVP customisations don't write the V2 interfaces by hand. They use a customer-shipped wrapper at tvopenplatform-mib3/src/Pages/Common/Component/MibComponentWrapper.cs that implements all the V1 and V2 interfaces and forwards the calls to an inner MibComponent-style class with [AjaxMethod]-decorated methods:

namespace GVP.Mib3.ScheduleModificationsReact;

public class ScheduleModificationsComponent
    : MibComponentWrapper<ScheduleModificationsMibComponent> { }

[Component("ScheduleModificationsReact", "{DICT:GVP_MIB3_SCHEDULEMODIFICATIONS/TITLE}")]
public class ScheduleModificationsMibComponent : MibComponent
{
    public ScheduleModificationsMibComponent(IMibApiClientLibrary apiClient)
        : this(new InstanceClient(apiClient), /* ... */ ) { }

    [AjaxMethod]
    public BaseItemsResponse<EpgScheduleSelectItem> GetInstancesItems()
    {
        var permission = Permissions.Get(MediaTypeNames.EpgSchedules);
        // ...
    }

    [AjaxMethod(Component.HttpMethod.Put)]
    public BaseResponse Save(EpgScheduleSaveReactModel request) { /* ... */ }
}

The empty subclass is the only thing referenced in MIB3UX_COMPONENTS.CLASS_NAME (e.g. GVP.Mib3.ScheduleModificationsReact.ScheduleModificationsComponent); the inner MibComponent carries the logic and the AJAX endpoints. The wrapper itself is not part of the BFF framework — it's a customer convenience adapter, so you'd need to copy or reimplement it for a new customisation, or write directly against IRenderableComponentV2.

[ComponentType] does not exist

Earlier versions of these docs claimed there is a [ComponentType("…")] attribute. There isn't. The component-type string the React shell uses is resolved in DisplayWorkflow.ResolveComponentType (in the MediaiBox.Cms.FrontEnd.Server assembly) with this precedence:

  1. MIB3UX_TEMPLATE_COMPONENTS.COMPONENT_VIEW_TYPE (a per-instance string override on the template-component row).
  2. IComponentTypeProvider.GetComponentType() if the C# class implements that interface.
  3. The C# class name, lower-cased, with Component stripped from the suffix. So ListComponentlist, MyWidgetComponentmywidget.

For a custom widget pick a key that won't collide with framework or other customisation strings — e.g. acme_my_widget — and either name your class AcmeMyWidgetComponent (relying on rule 3) or set COMPONENT_VIEW_TYPE = acme_my_widget on the template-component row.

Reflection-based instantiation

At every render request, the BFF:

  1. Reads the template's components from MIB3UX_TEMPLATE_COMPONENTS (joined to MIB3UX_COMPONENTS for ASSEMBLY_NAME + CLASS_NAME).
  2. Calls MibReflection.GetAssemblyFromBin(assemblyName). This first checks already-loaded assemblies in the AppDomain, then falls back to Assembly.LoadFrom(BaseDirectory + "bin/" + assemblyName + ".dll"), then Assembly.LoadFrom(BaseDirectory + assemblyName + ".dll"). No plugin folder, no AssemblyLoadContext isolation.
  3. Calls Activator.CreateInstance(type)parameterless constructor only. Constructor injection from a DI container is not used at this layer. Anything you need must come from Context (the ContextViewData the BFF sets via reflection), Configuration (your typed config DTO, populated from MIB3UX_PAGE_COMPONENT_CONFIGURATIONS), or a service you instantiate yourself inside MapSchema / MapData.
  4. Sets Context and Configuration on the instance, then calls Initialize(IMibApiClientLibrary, IPublicWorkflowFactory, IHttpContextAccessor).

This is the legacy reflection-based loader inherited from MVC-era MIB. Treat your component class as a thin coordinator: it picks up its inputs from Context and Configuration, calls your real domain code (a Mib API client, a helper class), and returns a DTO.

Context — what the request looks like to the component

Context is a ContextViewData (defined at MediaiBox.Cms.FrontEnd.Model/UI/Context/ContextViewData.cs):

Property Meaning
Context.IDs The selected mediatype ids on the current page (empty in list mode; one or more in edit / bulk-edit mode).
Context.User A UserViewData — id, email, time zone, groups, MediaTypePermissions, SourcePermissions, token hash.
Context.CopiedFrom If the page is being opened from a "copy" action, the source id; otherwise 0.
Context.IgnoreRelateds Used by the copy-with-relateds flow.
Context.PreviousPageKey The page the user came from (used by "Return" navigation).
Context.RootUrl, Context.Language, Context.RequestId, Context.MibLog Inherited from BaseContext — installation root URL, active language, the request id you'll see in logs, the per-request log accumulator.

MapSchema vs MapData — what goes where

Aspect MapSchema MapData
Stability Roughly stable per mediatype / per page. Changes per item / per save.
Cacheable? Often yes (the framework caches MediaTypeInformation). Generally no.
Per-user variation? Only what permission flags require. Yes — different items, different permission checks.
Allowed to return null? No — null is treated as a render failure (see below). No — same. Return an empty payload (e.g. new MyWidgetData { Items = [] }) instead.

Practical guidance:

  • Put column/field definitions, configuration, dropdown option lists, permission-derived UI flags into MapSchema.
  • Put row values, item field values, related-record references into MapData.
  • If your widget loads its own data over AJAX after mount, return an empty data DTO from MapData (not null). The React widget then fetches over AJAX on mount.

Resilience — what happens if you throw or return null

ComponentEvaluator (in MediaiBox.Cms.FrontEnd.Workflow.Mvc/Display/) wraps your MapSchema / MapData calls in a try/catch. Both an exception and a null return value are treated as a render failure — the framework logs a [Render-ComponentFailure] {json} line, marks the component Errored, and drops it from the response. The rest of the page still renders.

So:

  • Never rely on throwing to abort the whole page. If you really must, fail upstream (e.g. fail the BFF authentication middleware).
  • Don't return null from MapData even when there's no data to render; return an empty payload that matches your TData shape.

See ComponentEvaluator resilience gate for the full sequence.

Refresh — IRefreshableComponentV2

When the React side wants to re-pull just this component's data without a full page reload, implement IRefreshableComponentV2<TConfiguration, TViewData>:

public Task<MyWidgetData> MapRefreshData(
    DisplayComponentViewData viewData, CancellationToken ct)
{
    // Same shape as MapData would have returned.
    return Task.FromResult(new MyWidgetData { Items = ... });
}

The BFF exposes this through the refresh endpoint, and the React side invokes it directly via an @agilecontent/mib-api-connector client call keyed by pageKey + templateComponentKey. (The shell does not pass a per-widget refresh() callback in MibComponentProps — you call the endpoint yourself from inside the widget.)

For a refresh that re-fetches the entire page instead, prefer revalidateView() from MibComponentProps — it's the supported "reload everything" hook.

Persistence — saving form-like components

Form-shaped widgets save by POSTing to the standard BFF persist endpoint (POST /Pages/Persist for the legacy flow, the form-component-specific endpoints for V2). You don't need a special interface for that — the persist pipeline routes saved fields straight to MibObjects on the Data API.

If you need a custom server-side step on save (validation, side effects, batch ops), use a Custom Business Rule plugin instead of overloading the component class:

The legacy IPersistableComponent interface is not part of the V2 contract; it remains only for legacy MVC components that still drive the old MVC persist flow.

The React side

Where the widget lives

A custom React widget is part of a federated remote — a separate Vite app + Docker image whose remoteEntry.js the React shell loads at runtime. The minimal working scaffold ships in the hands-on tutorial artifacts (look at vite.config.ts + src/main.tsx); a larger production example is mini-agiletv.

The remote's vite.config.ts uses @originjs/vite-plugin-federation to expose ./all./src/main.tsx:

federation({
  name: 'customComponents',
  filename: 'remoteEntry.js',
  exposes: { './all': './src/main.tsx' },
  shared: ['react', 'react-dom', '@agilecontent/ui',
           '@agilecontent/mib-api-connector', 'react-i18next',
           '@agilecontent/mib-modules', 'styled-components', 'swr'],
}),

The federation entry point

src/main.tsx is a plain module with several named exports that the shell merges into its own surface. The shape (from real GVP code):

// src/main.tsx
import allComponents from './components';
import coreDecorators from './core-decorators';
import { getHandlers, seedDatabase } from 'common/mock-server';

export { default as translations } from './application/lang/mib/translations.json';
export { useFormChange } from './hooks/use-form-change';

export const handlers   = getHandlers;        // MSW handlers for Storybook
export const skeletons  = { /* per-page loading skeletons */ };
export const components = componentsWithSuspense;   // ← the dispatch map
export const decorators = coreDecorators;
export default components;

components is the dispatch map. Each key is a component-type string (matching ResolveComponentType on the BFF side); each value is a React component:

// src/components.ts
import { lazy } from 'react';

const ScheduleModifications = lazy(() => import('./components/schedule-modifications'));
const Dictionary            = lazy(() => import('./components/dictionary'));
// ...

export const components = {
  'schedule-modifications': ScheduleModifications,
  dictionaries:             Dictionary,
  // ...
};

Lazy-loading is recommended so the remote stays a single small remoteEntry.js that pulls in each widget's bundle only when its page opens.

How the shell dispatches

The shell pairs each component in the BFF response with a React component via its internal dispatcher (roughly apps/cms/src/common/helpers/components.ts in MibFrontEnd):

import { isCoreComponent, components as coreComponents } from 'core-components';
import { customComponents } from 'application/config/federation';

export const buildComponents = (components: ComponentSchema[]) =>
  components.map((component) => {
    if (isCoreComponent(component)) {
      return [component, coreComponents[component.type]];
    }
    return [component, customComponents ? customComponents[component.type] : null];
  });
  • coreComponents is the hard-coded record in packages/core-components/src/components/index.ts (keys: list, form, relatedList, asyncRelatedList, message, contentcriteria, dmm, epg, permissionList, plus stubs for htmlrender, orderedrelatedlist, quickupload, relatedautocomplete, relatedform, simplerelatedlist).
  • isCoreComponent returns true iff the type-string is a key of coreComponents. So the moment you pick a custom type-string outside this list, the shell looks for it in customComponents.
  • customComponents is the merged map from up to three federated remotes — customComponents, customComponents2, customComponents3 — see Federation slots below.
  • If neither map has the type, the shell renders a <Missing /> placeholder and logs a warning to the console. That's the symptom of a mismatched component-type string.

Federation slots

The shell can load up to three independent federated remotes:

// apps/cms/vite.config.ts (truncated)
const remoteConfigs = [
  { name: 'customComponents',  configKey: 'CUSTOM_COMPONENTS_URL'   },
  { name: 'customComponents2', configKey: 'CUSTOM_COMPONENTS_2_URL' },
  { name: 'customComponents3', configKey: 'CUSTOM_COMPONENTS_3_URL' },
];

This is what lets you split widgets across multiple customer-owned projects (e.g. GVP's main remote + a partner's remote + an internal team's remote) and still have one shell. The merger order is the order listed; later remotes override earlier ones if the same type-key is present in more than one.

If a slot's URL is not set, the dynamic import('customComponentsN/all') is skipped (silent no-op) and the slot contributes nothing.

The props contract

Each React widget receives a MibComponentProps<S, D> from the shell, defined at packages/api-connector/src/services/components/types.ts:

export type MibComponentProps<S, D> = {
  revalidateView: (
    page?: PageSchema | ((prev: PageSchema) => PageSchema),
    options?: { revalidate?: boolean }
  ) => void;
  componentKey: string;       // MIB3UX_TEMPLATE_COMPONENTS.TEMPLATE_COMPONENT_KEY
  title?: string;             // optional, from MapSchema
  schema: S;                  // ← what your MapSchema returned
  data:   D;                  // ← what your MapData returned
  pageKey: string;
  pageIds?: string;           // selected ids in edit / bulk-edit mode
  isBulkEdit?: boolean;
  setDirty?: (dirty: boolean) => void;
  isValidating?: boolean;
};
  • setDirty(true) integrates with the shell's "unsaved changes" banner — call it when the user edits, and setDirty(false) when the edit is reverted or persisted.
  • revalidateView(page?, opts?) triggers the shell to re-fetch the whole page (or applies a synchronous local update). It's the refresh-after-save hook.
  • The shell does not pass a per-widget refresh() callback. If you need granular refresh of just this widget, expose your own AJAX endpoint via IRefreshableComponentV2 on the C# side and call it from the React widget.

Minimal widget

import { useState } from 'react';
import type { MibComponentProps } from '@agilecontent/mib-api-connector';
import type { MyWidgetSchema, MyWidgetData } from './types';

export const MyWidget = ({
  schema, data, setDirty, revalidateView,
}: MibComponentProps<MyWidgetSchema, MyWidgetData>) => {
  const [localState, setLocalState] = useState(data.items);

  const onEdit = (next: typeof localState) => {
    setLocalState(next);
    setDirty?.(true);
  };

  return (
    <section className="my-widget">
      <h3>{schema.title}</h3>
      {/* ... render with @agilecontent/ui or your antd-derived components ... */}
      <button onClick={() => revalidateView()}>Reload page</button>
    </section>
  );
};

export default MyWidget;

Hooks exposed from the shell

Federated remotes can pull a small surface of helpers from @agilecontent/mib-modules and @agilecontent/mib-api-connector (both listed in the federation shared array, so they're deduped at load time). The actually-exported public hooks are limited:

Export Purpose From
useGlobalActions Register a top-right toolbar action (e.g. "Export TSV") that the shell renders next to "Save" / "Cancel". @agilecontent/mib-modules
useAccount Read the current user's account info. @agilecontent/mib-modules
useQuickEditContext The shell's per-page "quick edit" context. @agilecontent/mib-modules
useTranslation Standard react-i18next hook. Use for translations; same back-end source as {DICT:Section/Key} in C#. react-i18next
ContentEdition, ComponentList, Sorter, Filters, History, SavedFilters, GridPersonalization Components, not hooks. Re-usable widgets the shell ships. @agilecontent/mib-modules

Notes on hooks not in the public surface but widely used by customer customisations: useContentEdition, useFilters, useSorter, and useTranslate are not exported from @agilecontent/mib-modules's public API (packages/modules/src/index.tsx); customer code imports them through internal paths. The framework continues to use them and they are documented in React Hooks & Events — Shared hooks with the "internal" caveat. The supported public-API alternatives are to render <ContentEdition> yourself, use useGlobalActions to register a Save button, drive list filter/sort through the URL query string, and use react-i18next's standard useTranslation() for translations.

Styling and UI primitives

Federated widgets share the shell's CSS custom variables (see Theming). Use the variables (--color-highlight, --color-title, …) rather than hard-coding colours; this makes the same widget themeable across customers.

For per-widget styles, ship a CSS module or styled-components file inside your remote — the federation bundler will include it in remoteEntry.js.

For UI primitives — buttons, form fields, tables, modals, toasts — use @agilecontent/ui (the shell's curated antd wrapper) instead of importing antd directly. It's listed in the shell's federation shared array, so federated remotes get the exact same version the shell loaded; importing antd directly results in a double-bundled UI library with subtle bugs (broken modals, doubled ConfigProvider, etc.).

Storybook for isolation

Each component should ship with a *.stories.tsx file. The remote can include its own @agilecontent/mock-server MSW handlers (exported from main.tsx as handlers) so widgets can be developed end-to-end without a running BFF.

Database wiring

Every component lives on a page through a template. A page is not "code" — it's a row in MIB3UX_PAGES whose TEMPLATE_ID points at a MIB3UX_TEMPLATES row, which in turn has 1..N MIB3UX_TEMPLATE_COMPONENTS rows describing the layout. Each template-component points at a MIB3UX_COMPONENTS row that gives the C# assembly + class. Per-instance configuration is keyed in MIB3UX_PAGE_COMPONENT_CONFIGURATIONS.

The tables involved

Table What it holds Key columns when adding a component
MIB3UX_COMPONENTS The catalog of available component classes. One row per (assembly, class) combo. ID, NAME, COMPONENT_KEY, ASSEMBLY_NAME, CLASS_NAME
MIB3UX_TEMPLATES The catalog of templates (page layouts). ID, NAME, TEMPLATE_KEY
MIB3UX_TEMPLATE_COMPONENTS An instance of a component on a template. This is where the layout actually lives. ID, NAME, TEMPLATE_COMPONENT_KEY, ORDER, COMPONENT_ID (→ MIB3UX_COMPONENTS.ID), TEMPLATE_ID (→ MIB3UX_TEMPLATES.ID), PARENT_TEMPLATE_COMPONENT_ID, COMPONENT_VIEW_TYPE
MIB3UX_PAGES The catalog of pages (the URLs the React shell routes to). ID, NAME, PAGE_KEY, TEMPLATE_ID (→ MIB3UX_TEMPLATES.ID)
MIB3UX_PAGE_CONFIGURATIONS Per-page key/value config (e.g. API_URL). ID, NAME, CONFIGURATION_KEY, CONFIGURATION_VALUE, PAGE_ID
MIB3UX_PAGE_COMPONENT_CONFIGURATIONS Per-component-on-a-page key/value config (what Configuration properties get populated from). ID, NAME, CONFIGURATION_KEY, CONFIGURATION_VALUE, PAGE_ID, TEMPLATE_COMPONENT_ID
MIB3UX_MENU Sidebar entry that points at a page. ID, NAME, MENU_KEY, URL, PARENT_MENU_ID, ORDER, ICON_URL, OWNER

COMPONENT_VIEW_TYPE is the column that overrides the React type-key resolution. Set it when you want the same C# class to render as a different React widget — see Mixing core/custom.

COMPONENT_KEY, TEMPLATE_KEY, TEMPLATE_COMPONENT_KEY, PAGE_KEY, MENU_KEY are the functional keys used by code; NAME is just for the admin UI's display.

Adding a new component to a new page — canonical SQL

This is the script the official sample (MibCustomComponent.Sample) ships with — adapted to V2. Run it once per environment as a migration.

DECLARE @component_id          INT,
        @template_id           INT,
        @template_component_id INT,
        @page_id               INT;

-- 1. Register the C# class
INSERT INTO MIB3UX_COMPONENTS
       (OWNER, NAME,                 COMPONENT_KEY,        ASSEMBLY_NAME,        CLASS_NAME)
VALUES (1,     'acme_my_widget',     'acme_my_widget',
        'Acme.Mib.MyWidget',
        'Acme.Mib.MyWidget.MyWidgetComponent');
SELECT @component_id = MAX(ID) FROM MIB3UX_COMPONENTS;

-- 2. Create the template (page layout)
INSERT INTO MIB3UX_TEMPLATES (OWNER, NAME, TEMPLATE_KEY)
VALUES (1, 'acme_my_widget_template', 'acme_my_widget_template');
SELECT @template_id = MAX(ID) FROM MIB3UX_TEMPLATES;

-- 3. Add the component instance to that template
INSERT INTO MIB3UX_TEMPLATE_COMPONENTS
       (OWNER, [ORDER], NAME, TEMPLATE_COMPONENT_KEY, COMPONENT_ID, TEMPLATE_ID, COMPONENT_VIEW_TYPE)
VALUES (1, 1, 'acme_my_widget_1', 'acme_my_widget_1',
        @component_id, @template_id,
        'acme_my_widget');   -- ← the React type-key
SELECT @template_component_id = MAX(ID) FROM MIB3UX_TEMPLATE_COMPONENTS;

-- 4. Create the page that uses that template
INSERT INTO MIB3UX_PAGES (OWNER, NAME, PAGE_KEY, TEMPLATE_ID)
VALUES (1, 'acme_my_widget_page', 'acme_my_widget_page', @template_id);
SELECT @page_id = MAX(ID) FROM MIB3UX_PAGES;

-- 5. Per-component configuration (read by your TConfiguration properties)
INSERT INTO MIB3UX_PAGE_COMPONENT_CONFIGURATIONS
       (OWNER, NAME, CONFIGURATION_KEY, CONFIGURATION_VALUE, PAGE_ID, TEMPLATE_COMPONENT_ID)
VALUES (1, 'acme_my_widget_page/MEDIA_TYPE',  'MEDIA_TYPE',  'MVP_MOVIES', @page_id, @template_component_id),
       (1, 'acme_my_widget_page/ALLOW_EDIT',  'ALLOW_EDIT',  'true',       @page_id, @template_component_id);

-- 6. (Optional) Sidebar menu entry
INSERT INTO MIB3UX_MENU (OWNER, NAME, MENU_KEY, URL, [ORDER], ICON_URL)
VALUES (1, 'My Widget', 'acme_my_widget_menu', '/acme_my_widget_page', 100, '...');

OWNER is the MIB user id; 1 is the admin user that ships with the seed.

How configuration is projected onto your DTO

ConfigurationLoader.DictionaryToObject (MediaiBox.Cms.FrontEnd.Workflow.Mvc/ConfigurationLoader.cs) walks the properties of your TConfiguration class and matches them against the flat CONFIGURATION_KEY → CONFIGURATION_VALUE rows by name. Matching is case-insensitive and underscore-insensitive — so a property AllowEdit matches a key ALLOW_EDIT, ALLOWEDIT, allow_edit, or allowEdit.

Supported scalar types: string, bool, int, Int64, int[], string[], Guid, Uri (relative URIs are resolved against the installation RootUrl), List<FormGrouping>, Dictionary<string,string>, Dictionary<string,Uri>, List<string>. Nested objects are not supported; flatten into multiple top-level keys instead.

public class MyWidgetConfiguration : IComponentConfiguration
{
    public string PageKey                     { get; set; }  // set by the framework
    public string TemplateComponentKey        { get; set; }  // set by the framework
    public string ParentTemplateComponentKey  { get; set; }  // set by the framework
    public string SerializedConfiguration     { get; set; }  // set by the framework (debug-friendly view)

    // Your own keys
    public string MediaType  { get; set; }
    public bool   AllowEdit  { get; set; } = true;
    public int    PageSize   { get; set; } = 20;
}

The four framework-managed properties are required by the IComponentConfiguration interface — the framework sets them after projection. Don't store anything in SerializedConfiguration; it's a human-readable snapshot the framework writes for logs/debugging.

Deployment

There are three artifacts to ship — depending on which quadrant of the mix-and-match matrix you're in, you may only need a subset.

Backend: custom C# DLL

Drop your customisation DLL flat into MibServer3's working directory. There is no plugins folder, no overlay, no isolated load context.

The base FrontEnd Server image looks like:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
COPY ["/publish", "./"]
ENTRYPOINT ["dotnet", "MediaiBox.Cms.FrontEnd.Server.dll"]

Everything in your publish/ directory ends up at /app/. Your Acme.Mib.MyWidget.dll sits next to MediaiBox.Cms.FrontEnd.Server.dll.

Build pipeline:

  1. Reference your MyWidget.csproj from a composition project (a csproj that has <ProjectReference> lines for every customer DLL — GVP's is tvopenplatform-mib3/Server/Mib3.Server.csproj).
  2. dotnet publish against that composition project. Set <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> on each customisation csproj so transitive references land in the same flat folder.
  3. Build a thin Docker image FROM <base-mib-server-image> with a COPY of your customisation DLLs over the base image's flat dir. See ~/Documents/agilecontent/Build/deploy_mib_frontend_dev264.sh for an example overlay script.
  4. Push to ECR; redeploy in the cluster.

There is a Custom/ subdirectory in the publish output but it currently contains only placeholder.txt and wwwroot/; the assembly loader does not probe it.

Common failure mode — your DLL builds locally but the BFF can't load it: check that all transitive DLLs are in the publish dir (newer versions of MSBuild sometimes prune them). The runtime exception will be a CouldNotLoadComponentAssemblyException in the BFF logs, naming the missing assembly.

Frontend: federated React remote

The remote is a separate Vite app and a separate Docker image. The shell container picks it up at runtime via environment variables.

# On the React shell container (apps/cms)
CUSTOM_COMPONENTS_URL=https://acme-mib-components.acme.example.com/assets/remoteEntry.js
CUSTOM_COMPONENTS_2_URL=
CUSTOM_COMPONENTS_3_URL=

apps/cms/start.sh transcribes these into /tmp/config.json, which the shell fetches at boot and uses to bind the federation imports. If a URL is empty, that slot is skipped silently.

Build pipeline for the remote:

  1. pnpm i && pnpm build produces dist/ with remoteEntry.js and the widget bundles.
  2. Build a Docker image that serves dist/ via nginx on port 4000 (the default; see MIBFrontEnd-GVP-components/Dockerfile).
  3. Deploy the image to a host the shell can reach (intranet, S3+CloudFront, another K8s service in the same cluster).
  4. Set CUSTOM_COMPONENTS_URL on the shell container to the deployed remoteEntry.js URL; restart the shell.

The shell does not rebuild when the remote redeploys. As long as the URL is stable, you can ship new widgets by redeploying just the remote.

Database: migration script

Run the SQL from Adding a new component to a new page against the customer's MIB3 database. For development, use MibDevelopmentTool (see Local Development) to apply the rows interactively. For production, ship them as a MibMigrator migration.

Wiring it up — end-to-end checklist

The exact steps depend on the matrix quadrant:

Custom backend + custom React (most flexible)

  1. C# side — create the class implementing IRenderableComponentV2<TCfg, TData, TSchema>. Build the DLL into your composition project's publish dir.
  2. React side — create the React component in your federated remote. Add it to src/components.ts under the type-key you chose (e.g. 'acme_my_widget'). Build and deploy the remote.
  3. Database — run the SQL from above. Make sure MIB3UX_TEMPLATE_COMPONENTS.COMPONENT_VIEW_TYPE matches your React key, or rely on the class-name fallback (AcmeMyWidgetComponentacmemywidget).
  4. Shell env — set CUSTOM_COMPONENTS_URL on the shell to point at your remote's remoteEntry.js.
  5. Verify — see Inspecting a render.

Custom backend + core React

  1. C# side — write the class. Emit the same Schema/Data shape the core component expects (see 6.0/React Components for list / form / relatedList schemas).
  2. No React work needed.
  3. Database — same SQL as above, but set COMPONENT_VIEW_TYPE = 'list' (or whichever core key) on the template-component row. The shell will dispatch to the built-in widget.
  4. No shell env change required if you don't have other custom widgets.

Core backend + custom React

  1. No C# work — the built-in component handles the server side.
  2. React side — write a widget that consumes the framework's Schema/Data shape (it'll be the same one the core React widget would have seen). Add it to your remote under a custom type-key (e.g. 'acme_movies_list').
  3. Database — replace the existing template-component's COMPONENT_VIEW_TYPE with 'acme_movies_list' so the shell dispatches to your widget instead of the built-in.
  4. Shell env — set CUSTOM_COMPONENTS_URL if not already set.

Core backend + core React

Just database rows. No code, no DLLs, no remote deploy. See Pages & Templates for the high-level page-building flow.

Inspecting a render in production

Three signals to debug a misbehaving component without a debugger:

  1. Server log line — search Kibana for evt:"RenderV2-Stats" AND pageKey:"<your page>". Each component has its own entry with outcome (Success, Errored, SkippedHidden, SuccessDataNull) plus schemaMs/dataMs timings. An Errored outcome will have a paired [Render-ComponentFailure] line carrying the stack trace.

  2. Network panelGET /api/v2/display/<page>/<id>. Look for your TemplateComponentKey in the response's components[]. If it's missing but other components are present, the component is Errored or SkippedHidden.

  3. React console — when a components[i].type doesn't resolve to any registered React widget (core or custom), the shell renders a <Missing /> placeholder and logs a console warning. That's the symptom of a mismatched component-type string between MIB3UX_TEMPLATE_COMPONENTS.COMPONENT_VIEW_TYPE (or the class-name fallback) and the React components: { ... } map.

Common failure-mode triage:

Symptom Likely cause
CouldNotLoadComponentAssemblyException in BFF logs DLL not present in /app/ of the FrontEnd Server container, or a transitive dependency is missing.
Errored outcome, stack trace ends in NullReferenceException on Context.MediaTypeInformation The page is in list mode (no IDs) and you assumed there'd be an item. Check Context.IDs.Any() first.
Component is in the response but the React widget shows nothing The type-key doesn't match the React components registry. Look at the network panel's components[i].type value, then look at the keys of src/components.ts in your remote.
Component is in the response, the type-key matches a core component, but the page errors with Cannot destructure property 'rules' of t.configuration as it is null You returned null from MapData or MapSchema — see Resilience.

See also