Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Power Apps

Adding Custom Pages to Model-Driven Apps: Canvas Power in a Model-Driven Shell

Custom Pages let you embed fully expressive canvas app experiences inside model-driven apps — dashboards, bulk editors, and operational hubs that standard forms and views simply can't deliver. This lesson covers the architecture, authoring, context-passing, security, and performance patterns you need to build Custom Pages that work in production.

🔥 Expert31 min readSep 22, 2026Updated Sep 22, 2026
Adding Custom Pages to Model-Driven Apps: Canvas Power in a Model-Driven Shell
On this page
  • Introduction
  • Prerequisites
  • What Custom Pages Actually Are (Architecture First)
  • The Canvas App Inside a Model-Driven App
  • How Custom Pages Differ from Embedded Canvas Apps
  • Creating a Custom Page in the Model-Driven App Designer
  • Step 1: Open the App in the Designer
  • Step 2: Add a New Page
  • Step 3: Author the Page in Studio
  • Step 4: Understanding the Page Object and Param()
  • Building the Dispatch Board: A Realistic Example
Connecting to Data
  • Building the KPI Bar
  • Building the Grouped Gallery
  • Building the Bulk Reassignment Panel
  • Adding the Custom Page to the Site Map
  • Setting the Navigation Icon and Display Name
  • Launching a Custom Page from a Record Context
  • Pattern 1: Site Map Entry with Record ID Parameter
  • Pattern 2: Inline Navigation Between Custom Pages
  • Security and Licensing Considerations
  • Model-Driven App Access
  • Custom Page Canvas App Access
  • Data Permissions
  • Licensing
  • Performance Patterns and Anti-Patterns
  • The Cold Start Problem
  • Data Loading Patterns
  • Component Reuse
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • "The Custom Page is blank or shows a loading spinner that never resolves"
  • "My Param() values are blank even when navigating from a record"
  • "Publishing changes to the Custom Page isn't reflected for users"
  • "The nested gallery is very slow"
  • "Navigation from a Custom Page back to the model-driven app is broken"
  • "The Custom Page component is missing after importing a solution"
  • Advanced Patterns
  • Using Custom Pages as Configuration Dashboards
  • Custom Pages as "Hub Pages" for Related Records
  • Integrating Custom Pages with Business Process Flows
  • Summary & Next Steps
  • Adding Custom Pages to Model-Driven Apps

    Introduction

    Picture this: you're building a model-driven app for a field service team. The standard form for a Work Order record is solid — technicians can update status, log parts, and capture notes. But your operations manager wants a consolidated dispatch dashboard that shows all open work orders grouped by territory, lets a dispatcher bulk-reassign records, and displays a real-time KPI bar at the top. None of that fits cleanly into a standard form or view. A form is scoped to a single record. A view is read-only by default and locked to a single table. And embedding a Power BI tile helps a little, but it's not interactive in the way dispatch needs.

    This is the exact gap that Custom Pages were designed to fill. Introduced as a generally available feature in 2022, Custom Pages are essentially canvas app screens that you embed inside a model-driven app. They live natively in the app's navigation, behave like any other page to end users, but give you the full expressiveness of canvas — galleries, custom layouts, multiple data sources, Power Fx logic, and complex UI interactions — all within the model-driven shell. They're not a workaround; they're an officially supported first-class citizen in the model-driven architecture.

    By the end of this lesson, you will understand exactly how Custom Pages work under the hood, how to build one the right way, how to pass context between the model-driven shell and the canvas page, and how to avoid the significant performance and maintainability pitfalls that trip up even experienced makers. This is a deep dive — expect to walk away with genuine competence, not just surface familiarity.

    What you'll learn:

    • The architecture of Custom Pages and how they relate to canvas apps and model-driven apps at the platform level
    • How to create and configure a Custom Page and add it to a model-driven app's site map
    • How to read and write Dataverse data from a Custom Page using the Dataverse connector and native tables
    • How to pass record context (record ID, entity name) from a model-driven form to a Custom Page
    • How to handle security, licensing, and sharing for Custom Pages correctly
    • Performance patterns and anti-patterns specific to embedded canvas pages in model-driven apps

    Prerequisites

    Before working through this lesson, you should already be comfortable with:

    • Building your first model-driven app: site map configuration, tables, forms, and views
    • Designing model-driven forms: form structure and embedded components
    • Basic canvas app authoring — if you're brand new to canvas, spend time with Build Your First Canvas App in Power Apps before continuing here
    • An understanding of Dataverse fundamentals: tables, rows, columns, and how Dataverse stores and relates data

    You'll also need a Power Apps environment with the Dataverse database provisioned, and at least a Power Apps Developer Plan or a paid license that includes model-driven apps.


    What Custom Pages Actually Are (Architecture First)

    Before you start clicking, you need a mental model of what Custom Pages are at the platform level — because the architecture drives several non-obvious behaviors that will confuse you if you skip this section.

    The Canvas App Inside a Model-Driven App

    A Custom Page is a canvas app that has been constrained to the "Page" hosting mode, which means it renders without the standard canvas app chrome (the app header, back button, and full-screen frame) and instead inherits the chrome from the surrounding model-driven app. The model-driven app acts as the host shell; the Custom Page is content rendered inside a particular navigation slot in that shell.

    Internally, the platform stores a Custom Page as a canvas app record in Dataverse — specifically in the CanvasApp table — and links it to the model-driven app as a SiteMapNode of type WebResource referencing that canvas app. When the model-driven app loads and a user navigates to the Custom Page node, the shell fetches and renders the canvas app within an iframe-like boundary. From the platform's perspective, you have two separate applications; from the user's perspective, it feels like one.

    This dual-app nature has several practical consequences:

    1. Separate versioning. The Custom Page (canvas app) has its own version history in the CanvasApp table. Publishing the model-driven app does not automatically publish the Custom Page. You must publish both separately.
    2. Separate solution management. When you add a Custom Page to a solution, the canvas app record and the site map node are separate solution components. If you export a solution and forget to include the canvas app component, the target environment will have a broken site map node.
    3. Session isolation. The canvas app inside the Custom Page runs in a constrained context. It cannot directly call JavaScript functions in the model-driven app's session, and you cannot use Xrm.WebApi calls from within the Custom Page. All data operations must go through Power Fx connectors.
    4. Rendering latency. Every time a user navigates to a Custom Page for the first time in a session, the canvas runtime must load. This cold-start cost is real — typically 2–5 seconds depending on the complexity of the page and the user's connection. We'll talk about how to mitigate this later.

    Key insight

    Think of a Custom Page as a "foreign object" that the model-driven host has agreed to render inside its navigation frame. The two halves communicate through a controlled interface — not through shared memory or direct function calls.

    How Custom Pages Differ from Embedded Canvas Apps

    If you've used the older embedded canvas app feature on model-driven forms (where you embed a canvas app as a form component), you already understand the basic idea of mixing canvas and model-driven. Custom Pages are a significant improvement over that pattern for several reasons:

    • Full-page real estate. An embedded canvas app on a form shares the page with the model-driven form UI — tabs, the business process flow bar, the command bar. A Custom Page gets the entire content area.
    • First-class navigation. Custom Pages appear in the site map and can be linked from the app's main navigation. Embedded form components are only reachable by navigating to a specific record and opening a specific form.
    • Better context passing. Custom Pages have a purpose-built mechanism for receiving context from the model-driven environment via the Param() function. Embedded form apps receive context via the ModelDrivenFormIntegration control, which is a messier interface.
    • Inline authoring. You can create and edit a Custom Page from directly inside the model-driven app designer, without leaving to Power Apps Studio. (Though as we'll discuss, you'll often want to open full Studio for complex work.)

    That said, embedded canvas apps on forms are still appropriate when you need the canvas UI to appear alongside a standard form — for instance, showing a Bing Maps rendering of an address next to the record's fields. Custom Pages are appropriate when you need the canvas UI to replace the standard page entirely.


    Creating a Custom Page in the Model-Driven App Designer

    Let's walk through creating a Custom Page from scratch. We'll use the dispatch dashboard scenario: a page called Dispatch Board that shows open work orders grouped by territory with bulk-assignment capability.

    Step 1: Open the App in the Designer

    Navigate to make.powerapps.com, select your environment, and find your model-driven app in Apps. Click the three-dot menu and choose Edit. This opens the modern model-driven app designer (not the classic designer — if you see a different UI, check the top-right for "Switch to modern designer").

    Step 2: Add a New Page

    In the left navigation pane of the designer, click New page. You'll see options for the page type. Select Custom page (as opposed to "Dataverse table" or "URL").

    You'll then be prompted to either:

    • Create a new custom page — which creates a brand-new canvas app scoped as a page
    • Use an existing custom page — which lets you reference a canvas app that has already been authored as a Custom Page

    Choose Create a new custom page. Give it a name that will be meaningful in the solution context — cr_dispatches_dispatchboard is better than Page1. Click Create. The system will create the canvas app record, link it to your model-driven app, and add it to the site map.

    Warning

    The name you give at creation becomes the canvas app's internal name in Dataverse. It cannot be easily changed later without breaking the site map reference. Choose a name with your organization's customization prefix and a clear functional description before you click Create.

    Step 3: Author the Page in Studio

    After creation, click Edit (the pencil icon) on the Custom Page in the designer. This opens Power Apps Studio in a specialized "Custom Page" mode. You'll notice the Studio environment looks nearly identical to standard canvas app authoring with a few differences:

    • The Screen dropdown at the top shows only one screen by default (Custom Pages support multiple screens, but navigation between them is handled differently)
    • The App object's OnStart formula still runs, but the app chrome properties (like App.BackEnabled) are overridden by the model-driven host
    • A Page object is available in the tree view — this is specific to Custom Pages and carries context passed in from the model-driven shell

    Step 4: Understanding the Page Object and Param()

    The most important architectural detail for Custom Pages is how they receive context from the model-driven host. When the model-driven app navigates to a Custom Page, it can pass parameters in the URL. Inside the canvas runtime, you read these parameters using the Param() function.

    The model-driven shell automatically injects two parameters when a Custom Page is launched from a record context:

    • recordId — the GUID of the record that was active when navigation occurred
    • entityName — the logical name of the table (e.g., cr_workorder)

    You read these like this:

    // In a label, gallery filter, or OnVisible formula:
    varRecordId = Param("recordId")
    varEntityName = Param("entityName")
    

    However — and this is critical — Param() is only populated when the Custom Page is navigated to with those parameters. If a user navigates to your Custom Page from the site map (not from a record context), Param("recordId") returns blank. Your page must handle both cases gracefully, or you'll get runtime errors or empty screens.

    Tip

    Always test your Custom Page in both contexts — launched from a record and launched from the site map. Use IsBlank(Param("recordId")) to branch your logic accordingly, and provide a meaningful fallback UI when no record context is available.

    For our Dispatch Board, the page is meant to be launched from the site map (not from a record), so we don't need record context. But let's also build a version that does use record context later.


    Building the Dispatch Board: A Realistic Example

    Now let's actually build the page. The Dispatch Board needs to:

    1. Display all Work Orders where Status = Open, grouped by Territory
    2. Allow a dispatcher to select multiple records and reassign them to a different technician
    3. Show aggregate KPIs at the top (total open orders, average age in days, orders due today)

    Connecting to Data

    Inside Studio, add the Microsoft Dataverse connector (it's the native connector, not the "Common Data Service (Legacy)" one — if you see both, choose the modern one). You'll see your tables appear in the data panel. Add Work Orders and Users (the system systemuser table, which holds technician records).

    Key insight

    Custom Pages use the same Power Apps connectors as any canvas app. This means the same delegation rules apply. If your Work Orders table has tens of thousands of records and you use Filter(WorkOrders, Status = "Open") without understanding delegation, you'll silently get truncated results at 500 or 2000 records. Review Canvas App Delegation Deep Dive to handle this correctly.

    For our dispatch scenario, we'll assume the Dataverse connector correctly delegates Filter on choice columns, which it does for Dataverse tables. Set your data source up as:

    // In App.OnStart, or use a named formula:
    ClearCollect(
        colOpenWorkOrders,
        Filter(
            'Work Orders',
            Status = 'Status (Work Orders)'.Open
        )
    )
    

    Use ClearCollect into a collection if you need to enrich the data with local calculations (like computing age in days). If the data is already queryable server-side, leave it in a live Filter() expression and avoid materializing it into a collection unnecessarily.

    Building the KPI Bar

    At the top of the screen, add a horizontal container (use the modern Layout container control set to horizontal). Inside it, add three Label controls with these formulas:

    // Total Open Orders
    Text(CountRows(colOpenWorkOrders), "0") & " Open Orders"
    
    // Average Age (days since Created On)
    Text(
        Average(colOpenWorkOrders, DateDiff(ThisRecord.'Created On', Now(), TimeUnit.Days)),
        "0.0"
    ) & " Avg Days Open"
    
    // Due Today
    Text(
        CountRows(
            Filter(colOpenWorkOrders, DateDiff(Now(), ThisRecord.'Due Date', TimeUnit.Days) <= 0)
        ),
        "0"
    ) & " Due Today"
    

    Style these as cards with a contrasting background. This gives dispatchers the at-a-glance context they need before drilling into individual orders.

    Building the Grouped Gallery

    The grouping requirement is the tricky part. Canvas apps don't have a native "grouped gallery" control. The standard approach is to use GroupBy() to create a nested collection, then render an outer gallery for groups and an inner gallery for items.

    // On App.OnStart or screen's OnVisible:
    ClearCollect(
        colGroupedOrders,
        GroupBy(colOpenWorkOrders, "cr_territory", "WorkOrders")
    )
    

    This creates a collection where each row represents a territory and has a nested table column called WorkOrders containing the matching records.

    In the screen, add a Vertical Gallery (the outer gallery) and set its Items to colGroupedOrders. Inside that gallery, add a Label for the territory name (ThisItem.cr_territory) and a nested Gallery (the inner gallery) with Items set to ThisItem.WorkOrders.

    Inside the inner gallery, show work order number, customer name, assigned technician, and a Checkbox control. The checkbox selection will power the bulk-reassignment.

    // Track selected records in a collection:
    // In the Checkbox's OnCheck formula:
    Collect(colSelectedOrders, ThisItem)
    
    // In the Checkbox's OnUncheck formula:
    Remove(colSelectedOrders, ThisItem)
    
    // Checkbox's Default formula (to show current state):
    ThisItem.'Work Order ID' in colSelectedOrders.'Work Order ID'
    

    Warning

    Nested galleries in canvas apps are a known performance concern. If the inner gallery items are pulling from a large delegated query rather than an already-loaded collection, each outer gallery item will fire its own separate query. Always materialize nested gallery data into a local collection (via GroupBy on a pre-loaded collection) rather than nesting live data source queries.

    Building the Bulk Reassignment Panel

    Add a Dropdown control connected to the Users table to let the dispatcher select a technician. Filter it to active users:

    Filter(Users, Status = 'Status (Users)'.Active)
    

    Add a Button labeled "Reassign Selected". In its OnSelect formula, use Patch() to update all selected records:

    // In Button.OnSelect:
    ForAll(
        colSelectedOrders,
        Patch(
            'Work Orders',
            LookUp('Work Orders', 'Work Order ID' = ThisRecord.'Work Order ID'),
            {'Assigned Technician': drpTechnician.Selected}
        )
    );
    // Refresh the data and clear selections
    ClearCollect(
        colOpenWorkOrders,
        Filter('Work Orders', Status = 'Status (Work Orders)'.Open)
    );
    ClearCollect(colSelectedOrders, Defaults('Work Orders'));  // effectively clears it
    Notify("Records reassigned successfully", NotificationType.Success)
    

    Note: ForAll with Patch executes the patches concurrently in Power Apps (it does not execute sequentially). For large selections this is generally desirable, but be aware that if a patch fails for one record, the others may still succeed. There's no built-in transaction wrapping. For critical operations, consider triggering a Power Automate flow that handles the updates with proper error handling and rollback capability.


    Adding the Custom Page to the Site Map

    Back in the model-driven app designer, the Custom Page has already been added to the navigation when you created it. You can drag it to the correct group in the navigation pane and rename the navigation label to "Dispatch Board."

    Click Save and publish on the model-driven app. Then, separately, click back into the Custom Page in the designer and click Publish for the canvas app component.

    Warning

    This two-step publishing requirement is one of the most common mistakes with Custom Pages. The model-driven app and the canvas app are versioned independently. If you save changes to the Custom Page in Studio but don't publish it, users will see the old version. If you publish the model-driven app but haven't published the canvas app, the navigation will work but the page content will be stale. Build a habit of always publishing both.

    Setting the Navigation Icon and Display Name

    In the designer's navigation pane, click your Custom Page node. In the right-hand properties panel, you can set:

    • Title: what users see in the navigation (e.g., "Dispatch Board")
    • Icon: choose from the available icon set or specify a web resource URL for a custom SVG icon
    • Sub-area type: should already be set to "Custom Page"

    Launching a Custom Page from a Record Context

    The second major use case is launching a Custom Page from a specific record — essentially replacing or supplementing the standard form with a richer canvas experience for certain scenarios.

    Pattern 1: Site Map Entry with Record ID Parameter

    You can add a site map node of type Custom Page and configure it to open in the context of whatever record was last active. The model-driven shell passes the record ID and entity name automatically when the user navigates to the Custom Page from a record's command bar or form navigation.

    However, the most reliable way to trigger a Custom Page from a record is to use a custom command bar button. You can add a button that opens the Custom Page and explicitly passes the current record ID. We cover this in depth in Customizing the Model-Driven Command Bar with Power Fx, but the core formula for a command bar button's OnSelect is:

    Navigate(
        'Dispatch Board Page',
        ScreenTransition.None,
        {recordId: Self.Selected.ItemId}
    )
    

    Wait — this syntax is for navigating between screens within canvas. To navigate to a Custom Page from the model-driven command bar, you use a different mechanism: the Navigate command in the command bar's Power Fx formula references the Custom Page by its Page name, not a canvas screen.

    The actual command bar formula looks like:

    Navigate('cr_dispatchboard_page', {recordId: Self.Selected.ItemId})
    

    Inside the Custom Page, read the passed parameter:

    // On the screen's OnVisible:
    Set(varWorkOrderId, Param("recordId"))
    

    Then use varWorkOrderId to look up the specific record:

    Set(
        varWorkOrder,
        LookUp('Work Orders', 'Work Order ID' = GUID(varWorkOrderId))
    )
    

    Note the GUID() wrapper — Param() always returns a text string, but most Dataverse primary key lookups expect a GUID-typed value. Wrapping with GUID() handles the type conversion.

    Pattern 2: Inline Navigation Between Custom Pages

    If your model-driven app has multiple Custom Pages and you want to navigate between them from within canvas (e.g., from the Dispatch Board to a Work Order Detail page that's also a Custom Page), you cannot use the standard canvas Navigate() function — that only works between screens within a single canvas app.

    To navigate between Custom Pages (which are different canvas apps), you use the Navigate() command available through the model-driven integration point:

    // Navigate to a different Custom Page with context:
    Navigate('cr_workorderdetail_page', {recordId: selectedOrder.'Work Order ID'})
    

    This tells the model-driven shell to load the target Custom Page and pass the parameter. The shell handles the actual URL routing.

    Note

    Navigation between Custom Pages goes through the model-driven shell's router, which means the browser URL updates and the back button works correctly. This is a meaningful advantage over embedded canvas apps, which had no clean way to participate in browser history.


    Security and Licensing Considerations

    Custom Pages inherit security in a nuanced way that you need to understand before deploying to production.

    Model-Driven App Access

    Users need a security role that grants access to the model-driven app itself. This is standard model-driven security — covered in Dataverse Security: Business Units, Security Roles, and Teams. Without this, users can't even load the app.

    Custom Page Canvas App Access

    Because a Custom Page is also a canvas app, the canvas app must be shared with users. When you create a Custom Page through the model-driven app designer, the platform automatically shares the canvas app with users who have access to the model-driven app. However, this automatic sharing only applies to users who are granted access at the time the model-driven app is published with the Custom Page included.

    If you add new users to the model-driven app later (by updating security roles), those users will see the model-driven app but may get an error ("You don't have permission to run this app") when they navigate to the Custom Page. You need to separately share the canvas app with those users or their security groups.

    The recommended approach for enterprise deployments is to share the Custom Page canvas app with an Azure AD security group that corresponds to your user population. This decouples the sharing step from the per-user role assignment.

    Warning

    Sharing a Custom Page canvas app directly with individual users from the canvas app's sharing panel works, but does not scale. If you have hundreds of users, maintaining individual sharing records becomes operationally painful. Use Azure AD groups.

    Data Permissions

    Inside the Custom Page, every Dataverse query runs under the current user's security context. The canvas app does not elevate privileges. If a user lacks read access to the Work Orders table in Dataverse (as defined by their security role), they will get empty results from Filter('Work Orders', ...) — not an error, just empty. This can cause very confusing UX where the page loads but appears to show no data.

    Build explicit empty-state UI: use If(CountRows(colOpenWorkOrders) = 0, ...) to show a meaningful message when no data loads, which helps distinguish "no records exist" from "you don't have access."

    Licensing

    Custom Pages require a Power Apps license that includes model-driven apps. The key distinctions:

    • Power Apps Premium (formerly Per App or Per User): includes model-driven apps and full Dataverse access — Custom Pages work fine
    • Microsoft 365 included Power Apps: does NOT include model-driven apps — users cannot access Custom Pages
    • Dynamics 365 licenses: typically include model-driven app access for the relevant workloads

    If your organization is licensing users with the "Power Apps per app" plan (1 or 3 app passes), each Custom Page counts as an app for licensing purposes in some configurations. Confirm with your Microsoft licensing agreement. See Understanding Power Apps Licensing for a full breakdown.


    Performance Patterns and Anti-Patterns

    Custom Pages have a performance profile that differs from both standard model-driven pages and standalone canvas apps. Understanding these differences lets you make architectural decisions that keep the app responsive.

    The Cold Start Problem

    When a user first navigates to a Custom Page in a session, the canvas runtime must initialize. This includes:

    • Loading the canvas app manifest from the Dataverse CanvasApp table
    • Downloading the app's resources (images, component definitions, connector configurations)
    • Executing App.OnStart
    • Executing the first screen's OnVisible

    This cold start can take 2–8 seconds on typical corporate networks. After the first load, navigation away from and back to the Custom Page within the same session is faster because the runtime stays partially warm.

    Mitigation strategies:

    • Keep App.OnStart lightweight. Do not load large collections or fire multiple connector calls at startup. Defer data loading to screen-level OnVisible handlers
    • Use named formulas (in the App.Formulas property) instead of collecting data in OnStart — named formulas are lazy-evaluated and don't block the startup sequence
    • Minimize the number of distinct connectors. Each connector initialization adds cold-start cost. A Custom Page that uses only the Dataverse connector (native) loads faster than one using Dataverse + SharePoint + a custom connector

    Tip

    If you have a Custom Page that users visit frequently, consider placing a navigation link to it in the app's top-level navigation rather than burying it three levels deep. Users who navigate to it early in their session will experience the cold start at a low-cost moment rather than in the middle of a critical workflow.

    Data Loading Patterns

    The most common performance mistake in Custom Pages is over-fetching data. Consider this pattern:

    // Anti-pattern: loading all work orders on startup, then filtering in galleries
    ClearCollect(colAllWorkOrders, 'Work Orders')  // loads every record!
    

    And the corrected pattern:

    // Better: filter at the server, load only what you need
    ClearCollect(
        colOpenWorkOrders,
        Filter('Work Orders', Status = 'Status (Work Orders)'.Open)
    )
    

    But even better for large tables is to avoid ClearCollect entirely and let the gallery's Items property handle the delegated query directly:

    // Gallery Items (live query, fully delegated):
    Filter('Work Orders', Status = 'Status (Work Orders)'.Open)
    

    This avoids materializing the data into a collection (which blocks the UI during the collection load) and keeps the query server-side.

    The tradeoff: live queries re-execute every time the gallery is rendered. For aggregations like CountRows and Average in the KPI bar, a live query means the KPI formulas re-query the server every screen render. For those aggregate widgets, pre-loading into a collection (once, in OnVisible) is the right choice. Use a hybrid approach: live query for the gallery, pre-loaded collection for the KPIs.

    Component Reuse

    If your model-driven app has multiple Custom Pages that share UI elements (a header bar, a color scheme, a notification pattern), you should build those as canvas components and publish them from a component library. Each Custom Page that imports the component library gets a cached copy at runtime, and updates to the library propagate to all consuming Custom Pages.

    This is particularly important for enterprise deployments where Custom Pages are maintained by a team rather than a single maker. Component libraries create the separation of concerns that makes large-scale canvas development manageable.


    Hands-On Exercise

    Work through all five parts to build a complete Custom Page integration.

    Scenario: You're building on a model-driven app for a case management system. The app has a Cases table with columns: Title, Status (choice: Open, In Progress, Resolved, Closed), Priority (choice: Low, Medium, High, Critical), Assigned Agent (lookup to User), and Created On (date).

    Part 1: Create the Custom Page

    1. Open your model-driven case management app in the modern designer
    2. Click New page > Custom page > Create a new custom page
    3. Name it [yourprefix]_cases_agentboard and click Create
    4. Click Edit to open the page in Studio

    Part 2: Build the KPI Header

    1. Add a horizontal layout container at the top of the screen, height 120px
    2. Inside it, add three label controls showing:
      • Count of Open + In Progress cases
      • Count of Critical priority cases
      • Count of cases created in the last 7 days
    3. Use named formulas in App.Formulas for the aggregates rather than collecting data in OnStart

    Named formula syntax (in App.Formulas):

    ActiveCases = Filter('Cases', Status <> 'Status (Cases)'.Closed And Status <> 'Status (Cases)'.Resolved);
    CriticalCases = Filter(ActiveCases, Priority = 'Priority (Cases)'.Critical);
    RecentCases = Filter(ActiveCases, DateDiff('Created On', Now(), TimeUnit.Days) <= 7);
    

    Then your KPI labels simply reference CountRows(ActiveCases), CountRows(CriticalCases), and CountRows(RecentCases) — no startup loading required.

    Part 3: Build the Case Gallery

    1. Add a vertical gallery below the KPI header
    2. Set Items to ActiveCases (your named formula)
    3. Show Case Title, Status, Priority, and Assigned Agent in each gallery item
    4. Add a colored left border that changes by priority (red for Critical, orange for High, yellow for Medium, gray for Low)
    5. Use Switch(ThisItem.Priority, 'Priority (Cases)'.Critical, Red, ...) on a rectangle's Fill property

    Part 4: Add a Filter Panel

    1. Add a dropdown for filtering by Agent (Items: Filter(Users, 'Is Disabled' = false))
    2. Add a dropdown for filtering by Status (Items: Choices('Cases'.Status))
    3. Update the gallery's Items to respect the filters:
    Filter(
        ActiveCases,
        (drpAgent.Selected.FullName = "All" Or 'Assigned Agent'.'Full Name' = drpAgent.Selected.FullName),
        (drpStatus.Selected.Value = "All" Or Status = drpStatus.Selected)
    )
    

    Part 5: Publish and Verify

    1. Save and publish the Custom Page from within Studio
    2. Go back to the model-driven app designer and verify the Custom Page appears in the navigation
    3. Save and publish the model-driven app
    4. Navigate to the running app and test the Custom Page
    5. Try navigating away and back — verify the gallery data refreshes

    Common Mistakes & Troubleshooting

    "The Custom Page is blank or shows a loading spinner that never resolves"

    Cause: Almost always a security issue. The user doesn't have the canvas app shared with them, or their Dataverse security role doesn't grant read access to the tables the Custom Page queries.

    Fix: Go to the canvas app record (find it in the Apps list in Power Apps Studio, where it will appear with the same name as your Custom Page). Share it with the appropriate users or Azure AD group. Separately, verify the user's Dataverse security role includes at least Read access on all tables referenced in the Custom Page.

    "My Param() values are blank even when navigating from a record"

    Cause: Param() is populated at app load time, not dynamically as the user navigates. If the Custom Page was already loaded (warm) when the user navigated to it from a record context, the Param() values from the original load are still in effect.

    Fix: If you need to handle the case where a user might navigate to the Custom Page from different record contexts in the same session, use screen-level OnVisible to re-read Param() into a variable. The Param() function re-evaluates when the screen becomes visible:

    // Screen.OnVisible:
    Set(varRecordId, Param("recordId"))
    

    But be aware: if the model-driven shell doesn't re-navigate to the Custom Page (it just shows the already-loaded canvas page), OnVisible may not fire again. Test this scenario explicitly.

    "Publishing changes to the Custom Page isn't reflected for users"

    Cause: Browser caching. Canvas apps are aggressively cached. Even after publishing, users may see the old version.

    Fix: Instruct users to do a hard refresh (Ctrl+Shift+R or Cmd+Shift+R). For production deployments, consider appending a version query parameter to force a cache bust — though this is handled automatically by the platform in most cases. If the issue persists, verify that you actually clicked Publish (not just Save) in Studio.

    "The nested gallery is very slow"

    Cause: The inner gallery is firing a separate Dataverse query for each outer gallery item, resulting in N+1 query behavior.

    Fix: Pre-load the full dataset into a collection using ClearCollect and GroupBy as described in the building section above. All gallery rendering then happens against in-memory data, and there are no per-row network calls.

    "Navigation from a Custom Page back to the model-driven app is broken"

    Cause: Using canvas Back() function inside a Custom Page. Back() navigates within the canvas app's screen history, not the model-driven app's navigation history.

    Fix: Use the model-driven navigation function instead. In the command bar context or from a button, use Navigate() with a target that the model-driven shell recognizes — either a table entity list or another Custom Page. For returning to a record view, use:

    Navigate('Work Orders', {recordId: varWorkOrderId})
    

    "The Custom Page component is missing after importing a solution"

    Cause: The canvas app component was not included in the solution export. Solutions require explicit addition of canvas app records.

    Fix: In the solution, click Add existing > App > Canvas app and explicitly add the Custom Page canvas app. Re-export and re-import. Going forward, treat the canvas app component as a first-class solution artifact that must be managed alongside the model-driven app and site map.

    Note

    When using Dataverse solutions for ALM (Application Lifecycle Management) with Custom Pages, also ensure your solution has an explicit dependency on the Custom Page component. The site map node automatically depends on the canvas app, but if your team uses manual solution exports, it's easy to forget to include it. Consider using Power Platform pipelines or Azure DevOps extensions to automate solution packaging and include all components.


    Advanced Patterns

    Using Custom Pages as Configuration Dashboards

    One excellent use of Custom Pages is building admin/configuration interfaces that don't fit the standard record-centric model-driven pattern. For example, a mapping table editor where admins can bulk-update lookup categories, or a configuration wizard that walks through setting up defaults for a deployment.

    These pages often don't need record context at all. They connect directly to configuration tables in Dataverse and expose rich editing UI (editable galleries, multi-column forms, drag-to-reorder via Sort + up/down buttons) that would be awkward or impossible to build in standard model-driven forms.

    The key architectural principle: use the model-driven shell for navigation consistency but use canvas expressiveness for the interaction model. Your operations team gets a consistent app experience; the configuration UI gets the flexibility it needs.

    Custom Pages as "Hub Pages" for Related Records

    Another powerful pattern is using a Custom Page as a hub that shows data from multiple related tables simultaneously — something standard model-driven forms can only approximate via subgrids.

    Imagine a Project Management app where a Project record needs to show open Tasks, active Milestones, recent Documents, and team member allocations all in one view without tab-switching. A Custom Page receiving the project record's ID via Param("recordId") can query all four tables simultaneously and render them in a custom layout.

    The key formula pattern for multi-table parallel loading:

    // In Screen.OnVisible, use Set with record-typed variables for fast parallel queries:
    Concurrent(
        Set(varProject, LookUp('Projects', 'Project ID' = GUID(Param("recordId")))),
        ClearCollect(colTasks, Filter('Tasks', 'Project'.'Project ID' = GUID(Param("recordId")))),
        ClearCollect(colMilestones, Filter('Milestones', 'Project'.'Project ID' = GUID(Param("recordId"))))
    )
    

    Concurrent() fires all three operations in parallel rather than sequentially, significantly reducing total load time when all operations are independent.

    Integrating Custom Pages with Business Process Flows

    Custom Pages and Business Process Flows can coexist in a model-driven app, but they serve different purposes. A Business Process Flow guides users through stages on a record — it's record-centric and appears at the top of a form. A Custom Page replaces the entire page for a navigation slot.

    The integration pattern is: use a BPF to manage the record-level lifecycle (stages: New → Assigned → In Progress → Resolved), and use a Custom Page as an operational view that aggregates across records. The BPF ensures data quality through stages; the Custom Page gives operational visibility.

    You can even read a record's BPF stage from a Custom Page using the Filter on the ProcessStage and ProcessSession tables (the BPF internal tables), though this requires understanding Dataverse's BPF data model and is an advanced topic.


    Summary & Next Steps

    Custom Pages bridge the gap between the structured, data-integrity-focused model-driven experience and the flexible, expressive canvas experience. When you need full-page real estate for a canvas UI within a model-driven app's navigation — for dashboards, operational hubs, configuration interfaces, or multi-record interaction patterns — Custom Pages are the right tool.

    The key principles to carry forward:

    Architecture: A Custom Page is a canvas app in "page mode" rendered by the model-driven shell. Publish both the canvas app and the model-driven app separately. Manage both as solution components.

    Context: Use Param("recordId") and Param("entityName") to receive record context. Always handle the case where these params are blank. Use GUID() to convert the string param to the type Dataverse expects.

    Security: Share the canvas app with Azure AD groups, not individuals. Data access inside the Custom Page runs under the user's Dataverse security context — no privilege elevation.

    Performance: Keep App.OnStart lightweight. Use named formulas for lazy evaluation. Use Concurrent() for parallel data loading. Pre-load grouped data into collections before rendering nested galleries.

    ALM: Explicitly include the canvas app component in your solution. Never rely on implicit dependencies for Custom Pages.

    Next, consider going deeper on:

    • Customizing the Model-Driven Command Bar with Power Fx — to add buttons that launch Custom Pages from record contexts with the correct parameters
    • Power Apps Components: Build Reusable UI Elements for Enterprise Scale — to share UI elements across multiple Custom Pages
    • Canvas App Performance Profiling in Production — to diagnose and fix performance issues in Custom Pages using telemetry

    Custom Pages are one of the most powerful tools in the model-driven maker's toolkit precisely because they extend the platform's native capabilities without abandoning its structure. Master them and you can build model-driven apps that handle nearly any UX challenge your organization throws at you.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Model-Driven Apps & Dataverse

    Previous

    Customizing the Model-Driven Command Bar with Power Fx

    Next

    Dataverse Alternate Keys, Duplicate Detection, and Data Quality

    Related Insights

    Power AppsExpert

    Canvas App Multi-Step Wizard Pattern: Building Guided Data Entry Flows with Screen Sequences, Progress Indicators, and Conditional Branching

    27 min
    Power AppsPractitioner

    Configuring Dataverse Webhook Endpoints and Service Endpoint Bindings: Triggering External System Notifications on Table Events in Model-Driven Apps

    23 min
    Power AppsPractitioner

    Canvas App UX Patterns: Loading Spinners, Empty States, and Skeleton Screens for Async Data

    23 min

    On this page

    • Introduction
    • Prerequisites
    • What Custom Pages Actually Are (Architecture First)
    • The Canvas App Inside a Model-Driven App
    • How Custom Pages Differ from Embedded Canvas Apps
    • Creating a Custom Page in the Model-Driven App Designer
    • Step 1: Open the App in the Designer
    • Step 2: Add a New Page
    • Step 3: Author the Page in Studio
    • Step 4: Understanding the Page Object and Param()
    • Building the Dispatch Board: A Realistic Example
    • Connecting to Data
    • Building the KPI Bar
    • Building the Grouped Gallery
    • Building the Bulk Reassignment Panel
    • Adding the Custom Page to the Site Map
    • Setting the Navigation Icon and Display Name
    • Launching a Custom Page from a Record Context
    • Pattern 1: Site Map Entry with Record ID Parameter
    • Pattern 2: Inline Navigation Between Custom Pages
    • Security and Licensing Considerations
    • Model-Driven App Access
    • Custom Page Canvas App Access
    • Data Permissions
    • Licensing
    • Performance Patterns and Anti-Patterns
    • The Cold Start Problem
    • Data Loading Patterns
    • Component Reuse
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • "The Custom Page is blank or shows a loading spinner that never resolves"
    • "My Param() values are blank even when navigating from a record"
    • "Publishing changes to the Custom Page isn't reflected for users"
    • "The nested gallery is very slow"
    • "Navigation from a Custom Page back to the model-driven app is broken"
    • "The Custom Page component is missing after importing a solution"
    • Advanced Patterns
    • Using Custom Pages as Configuration Dashboards
    • Custom Pages as "Hub Pages" for Related Records
    • Integrating Custom Pages with Business Process Flows
    • Summary & Next Steps