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
ExtensionRegionfor free UI — a chip, a button, a banner. - Use
PropertyGroupwhen 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 GraphBuilder — ExtensionId (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();
}
}
GraphIdtargets 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 calledDescription..After("Description")— just below the item calledDescription.
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
- PAGES.md — the base page walkthrough this guide builds on
- GRAPHS.md — the
GraphBuilderandExtendGroupAPI - PROPERTY_COMPONENTS.md —
PropertyGroupreference