Learn how to embed a custom page inside a model-driven form, pass the host record's ID as context, surface related Dataverse data using Power Fx, and wire navigation so users stay inside the model-driven shell. A complete, production-ready walkthrough with a hands-on client dashboard exercise.

Picture this: your sales team is working inside a model-driven app, looking at an Account record. They need to see a beautifully formatted timeline of recent interactions, a custom risk score visualization, and a quick way to log a new activity — all without leaving the form. The out-of-the-box form controls get you close, but the layout is rigid, the data presentation is generic, and there's no easy way to blend in that risk score from a related custom table. You know this is a job for a canvas app, but you don't want to send users to a completely different app. You want that rich, pixel-perfect experience embedded directly in the model-driven form.
This is exactly what embedded canvas apps and custom pages enable. As of recent platform updates, Microsoft has pushed hard toward custom pages as the preferred mechanism — a middle ground that gives you canvas-style authoring flexibility while behaving like a proper model-driven citizen in navigation, security, and solution management. But getting record context to flow correctly into your canvas component, wiring up navigation so users don't get lost, and surfacing related Dataverse data without a waterfall of separate queries — these are the parts nobody explains clearly. This lesson fixes that.
By the end, you'll have the skills to configure a custom page embedded inside a model-driven form, pass the host record's primary key and fields into the canvas context, build navigation that feels native to the model-driven shell, and surface multi-table Dataverse data cleanly inside that embedded experience.
What you'll learn:
You should already be comfortable with the basics of model-driven app forms and their layout. If you haven't yet worked with tabs, sections, and subgrids on a form, the lesson on Designing Model-Driven Forms: Sections, Tabs, Subgrids, and Quick View Forms will bring you up to speed. You should also understand how custom pages fit into the broader navigation story — the Adding Custom Pages to Model-Driven Apps: Canvas Power in a Model-Driven Shell lesson covers that architecture. A working knowledge of Power Fx expressions and at least one Dataverse table you own in a development environment is assumed.
Before writing a single formula, you need a clear mental model of what you're actually building, because Microsoft offers two distinct approaches and conflates them in the documentation.
Embedded Canvas Apps (the older mechanism) are created directly from the form editor. You add a canvas app control to a form section, and Power Apps auto-generates a standalone canvas app that's bound to that section. The form passes a parameter object called ModelDrivenFormIntegration into the canvas app, which gives you access to the current record's data. This approach has been available since around 2019, it still works, and you'll encounter it in existing solutions. Its weaknesses are real though: the canvas app is a separate artifact from the model-driven app, solution management gets messy, the navigation model is awkward, and the ModelDrivenFormIntegration API has friction.
Custom Pages (the modern mechanism, generally available as of 2022) are canvas-authored pages that live inside the model-driven app's solution component. They're first-class citizens of the site map and can also be embedded in forms. When embedded in a form, a custom page receives context through a different, cleaner mechanism: the page's Param() function receives a JSON-encoded context object that includes the record ID and entity name. Navigation uses Navigate() and the model-driven shell's own navigation stack, so the browser back button and breadcrumbs work correctly.
Key insight
If you're starting a new project today, use custom pages. The embedded canvas app approach is still viable for maintaining existing solutions, but custom pages give you proper solution layering, cleaner context passing, and a navigation model that doesn't fight the model-driven shell.
For the remainder of this lesson, we'll build with custom pages. Where behavior differs for the older embedded canvas app approach, we'll call it out explicitly.
We'll build something realistic throughout this lesson. The scenario: a professional services firm uses a model-driven app built on Dataverse. Their Account table (mapped to clients) needs an embedded dashboard showing:
Engagement records (a custom table related to Account via one-to-many)The tables involved are account (standard) and cr7f3_engagement (custom, with a lookup to account). If you want to follow along with your own tables, the pattern is exactly the same — substitute your schema names throughout.
Start in Power Apps. Navigate to your solution (always work inside a solution — managing custom pages outside a solution creates deployment headaches you do not want).
In the solution, choose New > App > Page. Give the page a meaningful name: ClientEngagementDashboard. You're now in the canvas studio, but notice the differences from a standard canvas app: there's no explicit screen management for navigation in the same way, the app checker runs against model-driven compatibility rules, and you have access to the full Power Fx language including the Param() function.
Set the page's background to transparent (Fill = RGBA(0, 0, 0, 0)) so it blends into the form's theme rather than showing a hard white box. This is one of the small details that makes the embedded experience feel native rather than bolted-on.
Tip
Custom pages in the canvas studio default to a fixed-width layout. Switch to Responsive layout in the Settings panel before you build anything else. Embedded pages need to stretch and shrink with the form's container width, and retrofitting responsive layout after you've placed all your controls is painful.
When the model-driven shell embeds your custom page inside a form, it passes context to the page as a URL query parameter named recordId and another named entityName. The shell constructs something like:
?recordId={00000000-0000-0000-0000-000000000000}&entityName=account
Inside your custom page, you retrieve these values using Param():
// Read the record ID passed by the model-driven form
Set(varRecordId, Param("recordId"));
// Read the entity (table) logical name
Set(varEntityName, Param("entityName"));
Place this in the OnStart property of the App object (or the OnVisible property if you're using a screen inside the page). The Param() function is evaluated at load time, so these values are available from the moment the page renders.
There's an important nuance here: recordId is passed as a string, not a GUID type. When you use it to filter Dataverse queries, you need to be explicit about the type. If you're querying via a Dataverse connector, you'll convert it:
// Correct: explicit GUID conversion when filtering Dataverse
Set(varAccount,
LookUp(
Accounts,
accountid = GUID(varRecordId)
)
);
If you forget the GUID() conversion, your LookUp will silently return blank. No error, no warning — just an empty result. This is one of the most common sources of confusion for practitioners building their first embedded page.
Warning
Param() returns a blank value when the page is opened in the canvas studio (outside the model-driven shell), because there's no host form to supply the parameters. Always initialize fallback values for development: Set(varRecordId, Coalesce(Param("recordId"), "your-test-guid-here")). This lets you develop and preview with real data without needing to constantly republish and test inside the form.
Once you have the record ID, loading the account record is straightforward:
// In App.OnStart or Screen.OnVisible
Set(varRecordId, Coalesce(Param("recordId"), "replace-with-test-guid"));
Set(varEntityName, Coalesce(Param("entityName"), "account"));
Set(varAccount,
LookUp(
Accounts,
accountid = GUID(varRecordId),
{
AccountId: accountid,
Name: name,
OwnerId: '_ownerid_value',
HealthScore: cr7f3_healthscore,
PrimaryContact: primarycontactid.fullname
}
)
);
Notice that we're selecting only the columns we need using the record projection syntax. This is not just style — it controls the actual columns returned in the OData request and measurably reduces payload size for tables with many columns. If your Account table has a rich Dataverse data model with many columns, projecting your selections also prevents future-proofing problems where someone adds a large memo field that starts bloating your embedded page's load time.
Now surface this data in your page layout. Use labels bound to varAccount.Name, varAccount.cr7f3_healthscore, and so on. Add a Gallery control (or a vertical container if you're using responsive layout) for the relationship manager's name.
Loading the list of related Engagement records uses the same pattern — filter by the parent account ID:
// Load related engagements, sorted by most recent first
ClearCollect(
colEngagements,
SortByColumns(
Filter(
Engagements,
'_cr7f3_account_value' = GUID(varRecordId)
),
"createdon",
SortOrder.Descending
)
);
Note
When filtering on a lookup column in a Dataverse connector query, use the underlying relationship's value column name — the one prefixed with an underscore and suffixed with _value. In this case, the engagement's lookup to account is stored as _cr7f3_account_value. You find this by checking the table's column metadata in the Dataverse table editor or via the Power Apps maker portal's column details.
Bind this collection to a Gallery control:
Gallery.Items = colEngagementsThisItem.cr7f3_name, ThisItem.createdon, ThisItem.cr7f3_statusText(ThisItem.createdon, DateTimeFormat.ShortDate)Add a loading spinner using a BusyIndicator or simply a visible label bound to IsBlank(varAccount) while the data loads. Users notice a blank flash before data appears; controlling it deliberately makes the experience feel polished.
With your custom page saved and published, head back to the model-driven app's form editor. The process:
Account table, and open Forms.ClientEngagementDashboard — the page you just created.The form editor will ask you to specify the Table context. Select Account. This is what tells the shell which record context to pass via the recordId parameter when the form opens.
Set the embedded page height. By default it renders quite short — something like 300px. For a dashboard, you want at least 500-600px, or set it to fill the tab. Use the Height property in the form editor's component panel and enter a specific pixel value, or set the section to "grow to fit" if your layout is responsive.
Save and publish the form.
Tip
Always test the embedded page by opening an actual Account record in your model-driven app, not just by previewing the form in the editor. The editor preview doesn't supply real recordId parameters, so your data won't load and you'll see blanks everywhere. Open a real record in your dev environment after publishing.
Here's where many builders run into friction. Your embedded custom page is living inside a model-driven form. When a user clicks on an engagement in your gallery and you want to open that engagement's form — how do you do it without the page hijacking navigation and leaving the model-driven shell's chrome?
The answer is the Navigate() function using model-driven navigation targets. Custom pages have access to a special navigation mode that instructs the model-driven shell to perform the navigation:
// Navigate to an Engagement record's main form
Navigate(
'Engagements',
ScreenTransition.None,
{recordId: ThisItem.cr7f3_engagementid}
)
Wait — that syntax opens a screen inside the page, which isn't what we want. For navigating to model-driven forms from a custom page, use the Launch() function with the proper model-driven URL pattern:
// Launch the Engagement record in the model-driven app
Launch(
Concatenate(
"https://yourorg.crm.dynamics.com/main.aspx?etn=cr7f3_engagement&pagetype=entityrecord&id=",
Text(ThisItem.cr7f3_engagementid)
),
{},
LaunchTarget.Replace
)
LaunchTarget.Replace replaces the current page in the browser rather than opening a new tab. This keeps the navigation model consistent — the user stays in the app, and the model-driven shell handles the breadcrumb trail.
Warning
Using LaunchTarget.New (opening a new browser tab) breaks the navigation experience and confuses users who don't realize they've left the original form. Use it only when you genuinely need the user to reference two records side by side. For standard navigation within the app, always use LaunchTarget.Replace or LaunchTarget.Current.
For the more common case of opening the new record form — like creating a new Engagement pre-populated with the client ID — combine the URL pattern with query parameters:
// Open a new Engagement form, pre-populated with the parent Account
Launch(
Concatenate(
"https://yourorg.crm.dynamics.com/main.aspx",
"?etn=cr7f3_engagement",
"&pagetype=entityrecord",
"&extraqs=",
EncodeUrl(
Concatenate(
"cr7f3_account=",
varRecordId
)
)
),
{},
LaunchTarget.Replace
)
The extraqs parameter is model-driven's mechanism for pre-populating form fields on new record creation. The field name in extraqs corresponds to the logical name of the lookup column on the Engagement table that points to Account. When the new Engagement form opens, that field will be pre-filled.
In the example above, we hardcoded the org URL. That works in development but breaks when you move the solution to a different environment, which has a different URL. The production-grade solution is to use an environment variable to store the base URL.
If you've read up on configuring Dataverse environment variables, you know these are solution-aware configuration values that can be overridden at deployment time without touching the solution components themselves.
Create a text environment variable named cr7f3_AppBaseUrl with a default value of your dev org URL. In your custom page, reference it:
// Load the base URL from an environment variable
Set(
varBaseUrl,
LookUp(
'Environment Variable Values',
'Environment Variable Definition'.'Schema Name' = "cr7f3_AppBaseUrl",
Value
)
);
// Use it in navigation
Launch(
Concatenate(
varBaseUrl,
"/main.aspx?etn=cr7f3_engagement&pagetype=entityrecord&id=",
Text(ThisItem.cr7f3_engagementid)
),
{},
LaunchTarget.Replace
)
This pattern means your deployment to production needs only an environment variable value override — the custom page itself doesn't need to change.
One of the sneaky problems with embedded custom pages is that data doesn't automatically refresh when the user navigates away (to create a new Engagement, for instance) and then returns. The page has already loaded; its OnStart already ran. The user created a new record, came back, and the engagement list still shows the old data.
You have two main strategies:
Strategy 1: Refresh on Tab Activation. If your custom page is on a separate tab, you can use the form's onload event together with a Tab OnActivate event (via JavaScript) to force a page reload when the tab is clicked. This is reliable but requires a small JavaScript web resource.
Strategy 2: Manual Refresh Button. Simpler and often more appropriate — add a Refresh icon button to your custom page that re-runs the ClearCollect for your engagements collection:
// Refresh button OnSelect
ClearCollect(
colEngagements,
SortByColumns(
Filter(
Engagements,
'_cr7f3_account_value' = GUID(varRecordId)
),
"createdon",
SortOrder.Descending
)
);
Set the button's Tooltip to "Refresh engagements" and use a standard refresh icon. Users appreciate the explicit control more than you'd expect, especially in operational contexts where they're actively creating records.
Key insight
Don't try to auto-refresh on a timer inside an embedded page. Power Apps' Timer control inside an embedded page will keep firing even when the tab isn't visible, generating unnecessary Dataverse queries and potentially hitting API throttle limits for busy apps. Make refresh intentional.
Your custom page runs in the context of the signed-in user. When it queries Dataverse — whether for the host Account record or for related Engagements — those queries honor the user's security roles exactly as if they were making those queries from anywhere else in the app. If the user doesn't have read access to the cr7f3_engagement table, the Filter() call returns an empty collection, not an error. Your page should handle this gracefully:
// Check if we got data, and show a helpful message if not
If(
IsEmpty(colEngagements),
Set(varEngagementsMessage, "No engagements found, or you may not have access to engagement records."),
Set(varEngagementsMessage, "")
);
Bind a Label control's Visible property to Not(IsBlank(varEngagementsMessage)) and its Text to varEngagementsMessage. This turns a silent empty gallery into a clear user message.
For the security role configuration itself — making sure the right users have read access to your custom tables — the lessons on Dataverse Security: Business Units, Security Roles, and Teams and configuring Dataverse table permissions cover that territory.
If you're maintaining an existing solution that uses the embedded canvas app (not custom page) mechanism, here's what's different:
Context object: Instead of Param("recordId"), you use the ModelDrivenFormIntegration control's Item property:
// In an embedded canvas app (not custom page)
ModelDrivenFormIntegration.Item.'Account Name'
ModelDrivenFormIntegration.Item.accountid
The Item property exposes the record fields as a typed record — no GUID conversion needed for the ID, and you get field values directly without a separate LookUp. This is more convenient in some ways, but:
Navigation from embedded canvas app: The ModelDrivenFormIntegration.NavigateTo() function is used instead of Launch(). The syntax is different and more verbose. If you need to navigate to a record:
// In an embedded canvas app
ModelDrivenFormIntegration.NavigateTo(
{
pageType: "entityrecord",
entityName: "cr7f3_engagement",
entityId: Text(ThisItem.cr7f3_engagementid)
}
)
This feels more "official" than the Launch() URL approach, but in practice both work. The Launch() pattern used in custom pages is more portable.
Let's tie everything together. Here's a structured exercise you can complete in a development environment.
Setup (10 minutes):
Engagement with columns: Name (text, required), Status (Choice: Active, Completed, Cancelled), Notes (multiline text), Account (lookup to Account table).Build the custom page (30 minutes):
ClientEngagementDashboard.App.OnStart to:Set(varRecordId, Coalesce(Param("recordId"), "YOUR-TEST-ACCOUNT-GUID"));
Set(varEntityName, Coalesce(Param("entityName"), "account"));
Set(varAccount,
LookUp(Accounts, accountid = GUID(varRecordId))
);
ClearCollect(
colEngagements,
SortByColumns(
Filter(
Engagements,
'_cr7f3_account_value' = GUID(varRecordId)
),
"createdon",
SortOrder.Descending
)
);
varAccount.name and "Health Score: " & Text(varAccount.cr7f3_healthscore).colEngagements. Configure each gallery row to show the engagement name, status, and created date.OnSelect, add a Launch() call to open the engagement record.ClearCollect for colEngagements.Embed in the form (10 minutes):
ClientEngagementDashboard, and set the context table to Account.Test: Open an Account record in your dev environment. Click the "Engagements Dashboard" tab. Verify the account name and health score display correctly. Verify the engagement gallery populates. Click an engagement and verify you land on that record's model-driven form. Come back to the account, click "New Engagement," fill in the form, save it, return to the account, hit Refresh, and verify the new engagement appears in the list.
The gallery is empty and I don't know why.
Start with the GUID conversion check. Paste your test account GUID directly into a hardcoded Filter() formula — if that works, the issue is GUID(varRecordId). If it still doesn't work, check the lookup column name with the underscore prefix. Open the Power Apps connector's Data panel, expand the Engagement table, and look for the column that ends in _value pointing to Account. That's the column name you need in the Filter().
The embedded page shows "Something went wrong" or a blank white box. Usually a security issue or a publish state issue. Verify: (1) the custom page has been published (not just saved), (2) your user has access to the tables the page queries, (3) the page name in the form editor matches the actual page component name in the solution.
Param("recordId") returns blank even on a real record.
Check that you've set the Table context in the form editor's custom page component properties. If you skip that step, the model-driven shell doesn't know what record context to pass. Also verify the form is published — draft forms don't pass context.
Navigation with Launch() opens a new tab instead of staying in the app.
You passed LaunchTarget.New instead of LaunchTarget.Replace. Check also whether your browser or IT policy is blocking same-tab navigation for the crm.dynamics.com domain.
The page height is wrong — it's either too short or scrolls awkwardly.
In the form editor, select the section containing the custom page and explicitly set a pixel height. Also check inside the canvas studio: if your canvas page's height is set to App.Height (auto-sizing), the embedding sometimes doesn't size correctly. Try setting the Height property of the page's root container to a fixed value that matches what you've set in the form editor.
Data doesn't update after creating a new record and returning.
This is expected behavior — the page's OnStart doesn't re-run on navigation return. Add a manual refresh button as described above. If you absolutely need auto-refresh, look at using SetFocus() events tied to the tab becoming active via a JavaScript web resource that calls Xrm.Page.ui.tabs.get("tab_name").sections.get("section_name").controls.get("custom_page_name").refresh() — but this is an advanced customization and adds maintenance overhead.
Warning
Don't embed multiple custom pages on the same form. Each custom page creates its own data connection context, and having two active canvas runtimes on one model-driven form significantly degrades performance. If you need multiple panels of information, build them as separate screens or containers within a single custom page.
A custom page embedded in a model-driven form adds load time to the form. The model-driven form itself is already making multiple requests to Dataverse to load the record. Your embedded page then fires its own requests. Keep the following in mind:
Query selectively. Use the column projection pattern ({ Name: name, ... }) in your LookUp and Filter calls. Dataverse supports $select at the OData level, and the Power Apps connector uses it when you project columns. Pulling full rows from tables with 50+ columns wastes bandwidth.
Limit gallery rows. Don't load unlimited related records. Use FirstN() to cap the gallery:
ClearCollect(
colEngagements,
FirstN(
SortByColumns(
Filter(Engagements, '_cr7f3_account_value' = GUID(varRecordId)),
"createdon",
SortOrder.Descending
),
20
)
);
Twenty recent records is almost always enough for an at-a-glance dashboard. If users need the full history, link them to the model-driven subgrid or a dedicated view.
Load data in parallel. If you're loading multiple collections on startup, use Concurrent():
Concurrent(
Set(varAccount, LookUp(Accounts, accountid = GUID(varRecordId))),
ClearCollect(colEngagements, Filter(Engagements, '_cr7f3_account_value' = GUID(varRecordId)))
);
Concurrent() fires all the contained formulas simultaneously rather than sequentially, cutting load time roughly in half when you have two independent queries.
You now understand the full architecture of embedding a custom page inside a model-driven form: how Param("recordId") and Param("entityName") deliver context from the host form, how to convert that string ID to a GUID for Dataverse queries, how to surface related records with filtered collections, and how to wire navigation using Launch() with LaunchTarget.Replace so users stay in the model-driven shell.
The client engagement dashboard exercise gave you a complete, realistic pattern you can adapt for almost any embedded page scenario — swap Account for Opportunity, swap Engagements for Activities or Orders, and the mechanics are identical.
Where to go from here: