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.

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:
Before working through this lesson, you should already be comfortable with:
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.
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.
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:
CanvasApp table. Publishing the model-driven app does not automatically publish the Custom Page. You must publish both separately.Xrm.WebApi calls from within the Custom Page. All data operations must go through Power Fx connectors.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.
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:
Param() function. Embedded form apps receive context via the ModelDrivenFormIntegration control, which is a messier interface.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.
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.
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").
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:
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.
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:
OnStart formula still runs, but the app chrome properties (like App.BackEnabled) are overridden by the model-driven hostThe 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 occurredentityName — 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.
Now let's actually build the page. The Dispatch Board needs to:
Status = Open, grouped by TerritoryInside 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.
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.
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.
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.
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.
In the designer's navigation pane, click your Custom Page node. In the right-hand properties panel, you can set:
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.
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.
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.
Custom Pages inherit security in a nuanced way that you need to understand before deploying to production.
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.
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.
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."
Custom Pages require a Power Apps license that includes model-driven apps. The key distinctions:
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.
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.
When a user first navigates to a Custom Page in a session, the canvas runtime must initialize. This includes:
CanvasApp tableApp.OnStartOnVisibleThis 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:
App.OnStart lightweight. Do not load large collections or fire multiple connector calls at startup. Defer data loading to screen-level OnVisible handlersApp.Formulas property) instead of collecting data in OnStart — named formulas are lazy-evaluated and don't block the startup sequenceTip
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.
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.
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.
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
[yourprefix]_cases_agentboard and click CreatePart 2: Build the KPI Header
App.Formulas for the aggregates rather than collecting data in OnStartNamed 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
Items to ActiveCases (your named formula)Switch(ThisItem.Priority, 'Priority (Cases)'.Critical, Red, ...) on a rectangle's Fill propertyPart 4: Add a Filter Panel
Filter(Users, 'Is Disabled' = false))Choices('Cases'.Status))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
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.
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.
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.
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.
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})
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.
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.
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.
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.
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:
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.