Composable Pages

This guide shows how one module can add UI and data to a page that another module owns — without either module knowing about the other.

1. The Problem: One Page, Many Modules

Say the Sales module owns a Customer page. Tomorrow, the Financial module wants to add a "Credit & billing" section to it. The easy fix looks like this:

@if (FinancialsEnabled)
{
    <FinancialTab />
}
@if (ShippingEnabled)
{
    <ShippingTab />
}

This scales badly: the page must know every module, flag, and team, and each new one means editing it again. We want the opposite — the page shouldn't know about modules, and modules shouldn't edit the page.

2. The Idea: Pages Open Slots, Modules Drop Things In

   Sales module                    Financial module
   (owns the page)                 (adds content)
        │                                │
        │ "Customer page has a           │ "I want to put 'Credit info'
        │  group called Profile"         │  into Profile"
        ▼                                ▼
   ┌──────────────────────────────────────────┐
   │  Customer Page                           │
   │  ┌────────────────────────────────────┐  │
   │  │ Profile group                      │  │
   │  │   • Name                           │  │
   │  │   • Email                          │  │
   │  │   • Phone                          │  │
   │  │   ──────── slot opens here ──────  │  │
   │  │   • Credit info  ← from Financial  │  │
   │  └────────────────────────────────────┘  │
   └──────────────────────────────────────────┘

A page opens a named slot; modules drop content in by matching the name. Neither knows about the other. If a module is off, the slot is simply empty — nothing breaks.

3. Two Kinds of Slots

There are two kinds of slots. Pick the one that fits what you want to add.

Slot type Component What it accepts What you implement Register with
Free UI region <ExtensionRegion Id="..." /> Any UI IRegionExtension AddRegionExtension<T>()
Property group <PropertyGroup Path="..." /> UI and new data fields IPropertyGroupExtension AddPageExtension<T>()
  • Use ExtensionRegion for free UI — a chip, a button, a banner.
  • Use PropertyGroup when you also need to add new fields to the page's data, not just UI.

4. Example 1 — A Region Extension (the Chip)

The smallest example. The Financial module wants to add a "Verified customer" chip to the header of the Sales-owned Customer page.

Step A — The page opens a named slot

The Sales module's Customer page declares the slot:

<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
    <ExtensionRegion Id="FeatureExt.Customer.Header" />
</MudStack>

Step B — The Financial module fills the slot with a chip

@implements IRegionExtension

<MudChip T="string"
         Color="Color.Info"
         Variant="Variant.Filled"
         Icon="@Icons.Material.Filled.Verified">
    Verified customer
</MudChip>

@code {
    public string Id => "FeatureExt.Customer.Header";
}

Step C — Register it

builder.Services.AddRegionExtension<CustomerHeaderRegion>();

That's it. The IDs must match (case-insensitive). If nothing matches, the slot just shows nothing — pages don't break when modules are missing.

5. Example 2 — A Property Group Extension (Sales × Financial)

The main example. A PropertyGroup holds both UI and the data behind a section, so an extension can add new fields and new UI to show them.

Scenario A — Financial adds credit info to a Sales-owned Customer page

Before and after:

   Before:                          After (with Financial module loaded):
   Profile                          Profile
     Name                             Name
     Email                            Email
     Phone                            Phone
                                      Credit info   ← added by Financial

The page (Sales module):

<DataGraph Builder="CustomerFeatureExtSampleGraph.Build"
           DataSet="@_dataSet"
           EntityGuid="@EntityGuid">
    <PropertyGroup Path="Profile" Columns="2" ShowTitle="true" />
</DataGraph>

The page's data shape (Sales module):

public static GraphBuilder Build() => new GraphBuilder(RootEntity, ExtensionId)
    .Group("Profile", group => group
        .Property("Name")
        .Property("Email")
        .Property("Phone"));

The second argument to GraphBuilderExtensionId (for example "FeatureExt.Customer") — is the name that other modules will target.

The extension (Financial module):

@implements IPropertyGroupExtension

<MudPaper Class="pa-3">
    <MudText Typo="Typo.subtitle1">Credit info</MudText>
    <MudStack Spacing="2">
        <PropertyComponent Path="CreditLimit" />
        <PropertyComponent Path="OutstandingBalance" />
        <PropertyComponent Path="PaymentTerms" />
    </MudStack>
</MudPaper>

@code {
    public string GraphId => "FeatureExt.Customer";

    public void ExtendGraph(GraphBuilder graph)
    {
        graph.ExtendGroup("Profile", group => group
                .Property("CreditLimit")
                .Property("OutstandingBalance")
                .Property("PaymentTerms"))
            .After();
    }
}
  • GraphId targets the Customer page.
  • ExtendGroup("Profile", ...) loads the new fields from the server.
  • .After() places the content at the bottom of the group.

Skip ExtendGraph and the new paths never load — the PropertyComponents show nothing.

Scenario B — Financial adds money fields to a Sales Order page

Same idea on a different page. The Sales module also owns the Sales Order page; the Financial module adds money fields to it:

@implements IPropertyGroupExtension

<MudPaper Class="pa-3">
    <MudText Typo="Typo.subtitle1">Financial details</MudText>
    <MudStack Spacing="2">
        <PropertyComponent Path="TotalAmount" />
        <PropertyComponent Path="Currency" />
        <PropertyComponent Path="PaymentTerms" />
    </MudStack>
</MudPaper>

@code {
    public string GraphId => "FeatureExt.SalesOrder";

    public void ExtendGraph(GraphBuilder graph)
    {
        graph.ExtendGroup("Header", group => group
                .Property("TotalAmount")
                .Property("Currency")
                .Property("PaymentTerms"))
            .Before("Description");
    }
}

Where Does My Extension Land?

The last call — .Before(...) or .After(...) — picks the spot:

  • .Before() — top of the group.
  • .After() — bottom of the group.
  • .Before("Description") — just above the item called Description.
  • .After("Description") — just below the item called Description.

If two extensions land in the same spot, their relative order is not guaranteed unless you anchor them explicitly (e.g., .Before("...") / .After("...")).

6. Registration — One Line per Extension

Wire up every extension in your app's Program.cs:

builder.Services.AddCoreBlazor();

// One line per extension. Order = render order when no anchor is set.
builder.Services.AddPageExtension<CustomerCreditExtension>();
builder.Services.AddPageExtension<SalesOrderFinancialExtension>();
builder.Services.AddRegionExtension<CustomerHeaderRegion>();

Quick lookup:

Helper Use when your component implements
AddPageExtension<T>() IPropertyGroupExtension (or both interfaces)
AddRegionExtension<T>() only IRegionExtension
AddGraphPageInfrastructure() Call once per app. AddPageExtension already does this for you.

State. Blazor renders a fresh copy of your extension, separate from the one that ran ExtendGraph. Don't share state via class fields between them — use a scoped service or cascading parameter.

7. How It All Fits Together

App startup
  └─ AddPageExtension<T>() registers T under
     IGraphExtension / IPropertyGroupExtension / IRegionExtension

Page renders
  ├─ DataGraph builds the data shape
  │    ├─ GraphComposer finds extensions whose GraphId matches
  │    └─ Each one runs ExtendGraph(...)
  │         → saves (target group + spot) in
  │           PropertyGroupExtensionRegistry
  ├─ PropertyGroup renders its group "Profile"
  │    ├─ Asks the registry: "any extensions for Profile?"
  │    ├─ RegionOrdering mixes them in with the group's own items
  │    └─ Shows each extension as a fresh component
  └─ ExtensionRegion Id="..." renders
       ├─ Picks the IRegionExtensions whose Id matches
       └─ Shows each as a fresh component

8. Common Problems

My extension doesn't show up. Check that GraphId (or the region Id) matches the page exactly. Then check that you called AddPageExtension<T>() or AddRegionExtension<T>() in Program.cs.

My new fields don't load from the server. You probably forgot ExtendGraph. UI alone is not enough — without ExtendGraph the page never asks the server for the new fields, so there is nothing to show.

Two extensions overlap. Which one wins? Both show up. Use .Before(...) or .After(...) with a name if you care about deterministic order; otherwise relative order is not guaranteed.

9. See Also