Showing posts with label MicrosoftPowerPages. Show all posts
Showing posts with label MicrosoftPowerPages. Show all posts

Thursday, May 7, 2026

Liquid code in Power Pages - FetchXML

FetchXML is the native query language of Microsoft Dataverse. If you're building anything serious on the Power Platform — flows, plugins, portals, or canvas apps — you will hit a wall with OData eventually. This guide is the syllabus I wish existed when I started: every feature, every operator, real patterns you'll actually use in production.

1. What is FetchXML

FetchXML is an XML-based query language built specifically for Microsoft Dataverse (formerly Common Data Service / CDS). It's the closest thing Dataverse has to SQL — you write structured XML describing which records you want, and Dataverse translates it internally into the right database query.

It predates OData in the Dynamics ecosystem and remains more powerful for complex scenarios: multi-level joins, aggregates, cross-entity filtering, and paging over large datasets.

When to use FetchXML vs the alternatives

  • FetchXML — complex joins, aggregates, paging over 5,000 records, cross-entity conditions, working inside plugins or C# code
  • OData ($filter) — simple single-entity queries via Web API, quick REST calls, Power Automate "List Rows" without complex joins
  • LINQ (early-bound C#) — type-safe queries in plugin/tool code, good for simple lookups, limited aggregate support

Where FetchXML runs

  • Power Automate — "List rows" action on Dataverse connector → FetchXML input
  • Power Apps — via Collect with a Dataverse table or inside canvas app formulas using the connector
  • Plugins / C# — IOrganizationService.RetrieveMultiple(new FetchExpression(fetchXml))
  • Web API — GET /api/data/v9.2/entity?fetchXml=<encoded xml>
  • XrmToolBox — FetchXML Builder plugin for visual building and live testing
  • Power Pages / Portals — FetchXML-based data lists and liquid templates
<!-- Minimal valid FetchXML -->
<fetch>
  <entity name="account">
    <attribute name="name" />
    <attribute name="emailaddress1" />
  </entity>
</fetch>

2. Basic Structure

Every FetchXML query follows the same skeleton. Learn this once and everything else is layers on top.

The fetch element

AttributeValuesWhat it does
topintegerLimits result count (like SQL TOP). Omit for full result set.
distincttrue / falseRemoves duplicate rows. Essential when using outer joins.
no-locktrue / falseAdds NOLOCK hint. Use for read-heavy reporting queries.
aggregatetrue / falseSwitches the query to aggregate mode (section 6).
pageintegerPage number for paging (section 7).
countintegerPage size for paging. Max 5000.

entity, attribute, order

  • <entity name="logical_name"> — the table you're querying. Always use the logical name (lowercase, prefixed).
  • <attribute name="field_name" /> — columns to return. Omit completely to return all columns (not recommended for production).
  • <order attribute="field_name" descending="false" /> — sort order. Stack multiple for multi-column sort.
<fetch top="50" distinct="true" no-lock="true">
  <entity name="contact">
    <attribute name="fullname" />
    <attribute name="emailaddress1" />
    <attribute name="createdon" />
    <order attribute="createdon" descending="true" />
    <order attribute="fullname" descending="false" />
  </entity>
</fetch>
Tip: Never omit all <attribute> elements in production queries. Selecting all columns is a silent performance killer — you end up fetching columns you never use across potentially thousands of rows.

3. Filtering — <filter> and <condition>

Filters are where FetchXML earns its keep. The <filter> element wraps one or more <condition> elements and can be nested arbitrarily deep.

condition anatomy

<condition attribute="fieldname" operator="eq" value="somevalue" />

Complete Operator Reference

OperatorSQL EquivalentNotes
eq= valueExact match
ne!= valueNot equal
gt> valueGreater than
lt< valueLess than
ge>= valueGreater than or equal
le<= valueLess than or equal
likeLIKE '%val%'Use % wildcard. Leading % kills index — avoid.
not-likeNOT LIKEInverse of like
inIN (v1,v2,v3)Uses <value> child elements
not-inNOT INUses <value> child elements
betweenBETWEEN v1 AND v2Uses two <value> child elements
nullIS NULLNo value attribute needed
not-nullIS NOT NULLNo value attribute needed
eq-useridCurrent user IDNo value needed. Dynamic current user.
ne-useridNot current user
eq-businessidCurrent BU ID
today= CAST(GETDATE() AS DATE)Date fields only
tomorrowTomorrow's date
yesterdayYesterday's date
this-weekCurrent calendar week
this-monthCurrent calendar month
this-yearCurrent year
last-seven-daysRolling 7 days back
last-x-daysRolling N days backRequires value="N"
next-x-daysRolling N days forwardRequires value="N"
last-x-monthsRolling N months backRequires value="N"
next-x-monthsRolling N months forward
onExact dateDate fields, value = YYYY-MM-DD
on-or-before<= date
on-or-after>= date
begins-withLIKE 'val%'Index-safe unlike leading wildcard like
ends-withLIKE '%val'Avoid on large tables
contain-valuesMulti-select option setUses <value> child elements
not-contain-valuesMulti-select exclusion

Examples

<!-- IN operator -->
<condition attribute="statuscode" operator="in">
  <value>1</value>
  <value>2</value>
  <value>290560001</value>
</condition>

<!-- BETWEEN operator -->
<condition attribute="createdon" operator="between">
  <value>2024-01-01</value>
  <value>2024-12-31</value>
</condition>

<!-- Dynamic date operators -->
<condition attribute="modifiedon" operator="last-x-days" value="30" />

<!-- Null check -->
<condition attribute="emailaddress1" operator="null" />

<!-- Current user -->
<condition attribute="ownerid" operator="eq-userid" />

<!-- Like with trailing wildcard only (index-safe) -->
<condition attribute="name" operator="like" value="Contoso%" />

4. AND vs OR Logic

By default, all conditions inside a <filter> are ANDed together. Set type="or" to switch to OR. Nest filters to build complex boolean trees.

<!-- Default AND: active accounts in the US -->
<filter>
  <condition attribute="statecode" operator="eq" value="0" />
  <condition attribute="address1_country" operator="eq" value="United States" />
</filter>
<!-- OR: either status applies -->
<filter type="or">
  <condition attribute="statuscode" operator="eq" value="1" />
  <condition attribute="statuscode" operator="eq" value="290560001" />
</filter>
<!-- Mixed AND + OR nesting:
     (statecode = 0) AND (country = 'US' OR country = 'Canada') -->
<filter type="and">
  <condition attribute="statecode" operator="eq" value="0" />
  <filter type="or">
    <condition attribute="address1_country" operator="eq" value="United States" />
    <condition attribute="address1_country" operator="eq" value="Canada" />
  </filter>
</filter>
<!-- Real pattern: projects that are completed OR cancelled OR have no tasks -->
<fetch>
  <entity name="project">
    <attribute name="name" />
    <attribute name="statuscode" />
    <filter type="and">
      <condition attribute="ownerid" operator="eq-userid" />
      <filter type="or">
        <condition attribute="statuscode" operator="eq" value="290560002" />
        <condition attribute="statuscode" operator="eq" value="290560003" />
        <condition entityname="tsk" attribute="taskid" operator="null" />
      </filter>
    </filter>
    <link-entity name="task" from="regardingobjectid" to="projectid"
                 link-type="outer" alias="tsk">
      <attribute name="taskid" />
    </link-entity>
  </entity>
</fetch>
Watch out: The entityname attribute on a <condition> lets you filter on a joined entity's columns from the parent filter. This only works with outer joins and is how you implement "NOT EXISTS" patterns. The value of entityname must match the alias on the <link-entity>.

5. Link-Entities (Joins)

FetchXML joins are called link-entities. They work like SQL JOINs and can be nested multiple levels deep.

link-type values

link-typeSQL EquivalentWhen to use
innerINNER JOINOnly return parent records that have a matching child. Default if omitted.
outerLEFT OUTER JOINReturn parent records even when no child exists. Required for "not exists" patterns.

Basic inner join

<!-- Contacts and their parent Account name -->
<fetch>
  <entity name="contact">
    <attribute name="fullname" />
    <attribute name="emailaddress1" />
    <link-entity name="account" from="accountid" to="parentcustomerid"
                 link-type="inner" alias="acc">
      <attribute name="name" alias="accountname" />
      <attribute name="telephone1" alias="accountphone" />
    </link-entity>
  </entity>
</fetch>

Multi-level joins

<!-- Three levels deep: Task → Stage → Project -->
<fetch>
  <entity name="task">
    <attribute name="subject" />
    <attribute name="statuscode" />
    <link-entity name="stage" from="stageid" to="stageid"
                 link-type="inner" alias="stg">
      <attribute name="name" alias="stagename" />
      <link-entity name="project" from="projectid" to="projectid"
                   link-type="inner" alias="proj">
        <attribute name="name" alias="projectname" />
        <attribute name="statuscode" alias="projectstatus" />
      </link-entity>
    </link-entity>
  </entity>
</fetch>

Filtering on a joined entity

<!-- Contacts whose account is in a specific industry -->
<fetch>
  <entity name="contact">
    <attribute name="fullname" />
    <link-entity name="account" from="accountid" to="parentcustomerid"
                 link-type="inner" alias="acc">
      <attribute name="name" alias="accountname" />
      <filter>
        <condition attribute="industrycode" operator="eq" value="7" />
      </filter>
    </link-entity>
  </entity>
</fetch>

Cross-entity condition (entityname attribute)

This pattern filters on a linked entity's column from the parent entity's filter block. Most commonly used for "NOT EXISTS" — find parent records where no child row matches.

<!-- Accounts with NO contacts -->
<fetch distinct="true">
  <entity name="account">
    <attribute name="name" />
    <filter>
      <condition entityname="con" attribute="contactid" operator="null" />
    </filter>
    <link-entity name="contact" from="parentcustomerid" to="accountid"
                 link-type="outer" alias="con">
      <attribute name="contactid" />
    </link-entity>
  </entity>
</fetch>
Rule: Cross-entity conditions (entityname=) always require the join to be link-type="outer". An inner join would already exclude the non-matching rows before the NULL check runs.

6. Aggregate Queries

Set aggregate="true" on <fetch> to switch to aggregate mode. In this mode, every <attribute> must declare an aggregate function or be a groupby column.

Aggregate functions

FunctionDescription
countCount of all rows (including nulls)
countcolumnCount of non-null values in a column
sumSum of numeric column
avgAverage of numeric column
minMinimum value
maxMaximum value
<!-- Count contacts per account -->
<fetch aggregate="true">
  <entity name="contact">
    <attribute name="contactid" aggregate="count" alias="contactcount" />
    <attribute name="parentcustomerid" groupby="true" alias="accountid" />
  </entity>
</fetch>
<!-- Sum and average of estimated revenue grouped by industry -->
<fetch aggregate="true">
  <entity name="opportunity">
    <attribute name="estimatedvalue" aggregate="sum" alias="totalrevenue" />
    <attribute name="estimatedvalue" aggregate="avg" alias="avgrevenue" />
    <attribute name="opportunityid" aggregate="count" alias="dealcount" />
    <attribute name="industrycode" groupby="true" alias="industry" />
    <filter>
      <condition attribute="statecode" operator="eq" value="0" />
    </filter>
  </entity>
</fetch>
<!-- Distinct count: unique companies with open cases -->
<fetch aggregate="true">
  <entity name="incident">
    <attribute name="customerid" aggregate="countcolumn" distinct="true" alias="uniquecustomers" />
    <filter>
      <condition attribute="statecode" operator="eq" value="0" />
    </filter>
  </entity>
</fetch>

Grouping by date parts

<!-- Cases created per month -->
<fetch aggregate="true">
  <entity name="incident">
    <attribute name="incidentid" aggregate="count" alias="casecount" />
    <attribute name="createdon" groupby="true" dategrouping="month" alias="createmonth" />
    <attribute name="createdon" groupby="true" dategrouping="year" alias="createyear" />
    <order alias="createyear" descending="false" />
    <order alias="createmonth" descending="false" />
  </entity>
</fetch>
Note: Aggregate FetchXML results are capped at 50,000 groups. For reporting over very large datasets, consider Azure Synapse Link or Dataverse long-term data export instead.

7. Paging

Dataverse returns a maximum of 5,000 records per request. For anything larger you must page through results using either simple page numbering or paging cookies.

Simple page/count attributes

<!-- Page 2, 100 records per page -->
<fetch page="2" count="100">
  <entity name="contact">
    <attribute name="fullname" />
    <attribute name="createdon" />
    <order attribute="createdon" descending="true" />
  </entity>
</fetch>

Paging cookies (correct approach for large datasets)

Simple page numbering degrades at scale because the database still scans from the beginning every time. Paging cookies pass a server-side bookmark that picks up exactly where the previous page ended — far more efficient.

The response from Dataverse includes a @Microsoft.Dynamics.CRM.fetchxmlpagingcookie annotation. Decode it and inject it into the next request:

<!-- First request: no cookie -->
<fetch page="1" count="5000">
  <entity name="contact">
    <attribute name="fullname" />
    <order attribute="contactid" descending="false" />
  </entity>
</fetch>

<!-- Subsequent requests: inject the decoded cookie -->
<fetch page="2" count="5000"
  paging-cookie="&lt;cookie page=&quot;1&quot;&gt;&lt;contactid ... /&gt;&lt;/cookie&gt;">
  <entity name="contact">
    <attribute name="fullname" />
    <order attribute="contactid" descending="false" />
  </entity>
</fetch>
Important: Always include an <order> on a unique field (like the primary key) when paging. Without a deterministic sort order, you can get duplicate or skipped records across pages.

Paging in C# (plugin or tool)

// C# paging loop example
string fetchXml = @"<fetch count='5000'>
  <entity name='contact'>
    <attribute name='fullname' />
    <order attribute='contactid' />
  </entity>
</fetch>";

var allRecords = new List<Entity>();
int pageNumber = 1;
string pagingCookie = null;

while (true)
{
    string pagedFetch = PatchFetchXmlPage(fetchXml, pageNumber, pagingCookie);
    var result = service.RetrieveMultiple(new FetchExpression(pagedFetch));
    allRecords.AddRange(result.Entities);

    if (!result.MoreRecords) break;

    pagingCookie = result.PagingCookie;
    pageNumber++;
}

8. FetchXML in Power Automate

The Dataverse connector's List rows action accepts a FetchXML query directly. This unlocks joins, aggregates, and paging that you simply cannot do with OData filter expressions.

Basic setup

  1. Add action: Microsoft Dataverse → List rows
  2. Set Table Name to your entity
  3. Expand Advanced options
  4. Paste your FetchXML into the Fetch Xml Query field
<fetch top="500" no-lock="true">
  <entity name="contact">
    <attribute name="fullname" />
    <attribute name="emailaddress1" />
    <filter>
      <condition attribute="statecode" operator="eq" value="0" />
    </filter>
  </entity>
</fetch>

Injecting dynamic values safely

In Power Automate, FetchXML is a string. You can inject dynamic expressions using @{expression} syntax. The critical rule: never inject user-controlled input directly — always validate or use GUIDs from trigger/action outputs.

<!-- Safe: injecting a trigger GUID value -->
<fetch>
  <entity name="task">
    <attribute name="subject" />
    <attribute name="statuscode" />
    <filter>
      <condition attribute="regardingobjectid" operator="eq"
        value="@{triggerOutputs()?['body/accountid']}" />
      <condition attribute="createdon" operator="last-x-days"
        value="@{variables('DaysBack')}" />
    </filter>
  </entity>
</fetch>

Dynamic IN clause from an array variable

<!-- Step 1: Build <value> tags using Select action -->
<!-- Select "from": array of IDs, "map": concat('<value>', item(), '</value>') -->

<!-- Step 2: Join them and inject into FetchXML -->
<fetch>
  <entity name="project">
    <attribute name="name" />
    <filter>
      <condition attribute="projectid" operator="in">
        @{join(body('Select_ID_values'), '')}
      </condition>
    </filter>
  </entity>
</fetch>

Handling paging in Power Automate

The List Rows action does not auto-page. Use a Do Until loop: check the response for @odata.nextLink or use the paging cookie from the response headers to fetch the next batch.

Tip: For flows processing large volumes, consider moving paging logic to an Azure Function or plugin. Power Automate's 30-day / 30-second action limits make deep paging expensive.

9. FetchXML in Plugins / C#

Inside a Dataverse plugin or any code using IOrganizationService, pass your FetchXML to RetrieveMultiple via a FetchExpression.

Basic retrieval

string fetchXml = @"
<fetch top='50'>
  <entity name='contact'>
    <attribute name='fullname' />
    <attribute name='emailaddress1' />
    <filter>
      <condition attribute='statecode' operator='eq' value='0' />
    </filter>
  </entity>
</fetch>";

EntityCollection results = service.RetrieveMultiple(new FetchExpression(fetchXml));

foreach (Entity entity in results.Entities)
{
    string name = entity.GetAttributeValue<string>("fullname");
    string email = entity.GetAttributeValue<string>("emailaddress1");
}

Reading aliased join attributes

When you alias an attribute from a joined entity, it comes back in the Entity as an AliasedValue.

// FetchXML has: <attribute name="name" alias="accountname" /> on the link-entity
var aliasedValue = entity.GetAttributeValue<AliasedValue>("accountname");
string accountName = aliasedValue?.Value as string;

Reading aggregate results

// FetchXML aggregate result: alias="contactcount"
var aliasedCount = entity.GetAttributeValue<AliasedValue>("contactcount");
int count = Convert.ToInt32(aliasedCount?.Value);

Building FetchXML dynamically in C#

// Use XElement for safe construction — no string concatenation
var fetch = new XElement("fetch", new XAttribute("top", 100),
    new XElement("entity", new XAttribute("name", "contact"),
        new XElement("attribute", new XAttribute("name", "fullname")),
        new XElement("filter",
            new XElement("condition",
                new XAttribute("attribute", "statecode"),
                new XAttribute("operator", "eq"),
                new XAttribute("value", "0")
            )
        )
    )
);

string fetchXml = fetch.ToString();
var results = service.RetrieveMultiple(new FetchExpression(fetchXml));

10. FetchXML via Web API

You can execute FetchXML directly against the Dataverse Web API by passing it as a URL query parameter. This is useful for integrations, testing from Postman, or server-side code calling the API directly.

Endpoint pattern

GET https://<org>.api.crm.dynamics.com/api/data/v9.2/<entity_plural>?fetchXml=<url-encoded-xml>

URL-encode your FetchXML

<!-- Raw FetchXML -->
<fetch top="10">
  <entity name="contact">
    <attribute name="fullname" />
    <filter>
      <condition attribute="statecode" operator="eq" value="0" />
    </filter>
  </entity>
</fetch>

<!-- URL encoded (key characters) -->
<    →  %3C
>    →  %3E
"    →  %22
/    →  %2F
=    →  %3D

Full example in JavaScript

const fetchXml = `<fetch top="10">
  <entity name="contact">
    <attribute name="fullname" />
    <filter><condition attribute="statecode" operator="eq" value="0" /></filter>
  </entity>
</fetch>`;

const encoded = encodeURIComponent(fetchXml);
const url = `${orgUrl}/api/data/v9.2/contacts?fetchXml=${encoded}`;

const response = await fetch(url, {
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'OData-MaxVersion': '4.0',
    'OData-Version': '4.0',
    'Accept': 'application/json'
  }
});

const data = await response.json();
console.log(data.value); // array of records

Request formatted values

To get the display label of option sets (formatted values) in the response, add a Prefer header:

Prefer: odata.include-annotations="OData.Community.Display.V1.FormattedValue"

Formatted values then appear in the response as fieldname@OData.Community.Display.V1.FormattedValue.

11. XrmToolBox: FetchXML Builder

FetchXML Builder (by Jonas Rapp) is the fastest way to build, test, and debug FetchXML queries against a live environment. Every serious Dataverse developer should have it installed.

Key features to use

  • Visual query builder — drag-and-drop entities and fields, set filters via dropdowns. Good starting point even if you'll hand-edit the XML.
  • Execute and preview — run the query and see results instantly against your connected environment. No need to deploy a flow or plugin to test.
  • View as SQL — converts your FetchXML to approximate T-SQL. Useful for understanding what's happening under the hood and for writing the equivalent SQL for reporting.
  • Copy as code — exports the query as ready-to-use C#, JavaScript, or Power Automate-compatible string.
  • Aggregate mode toggle — switches the builder into aggregate mode with a checkbox.
  • Paging cookie test — lets you page through results directly in the tool.

Workflow tip

  1. Build the query visually in FetchXML Builder
  2. Switch to XML view and refine edge cases (nested filters, cross-entity conditions)
  3. Execute and validate results against real data
  4. Copy the final XML into your flow, plugin, or API call
Tip: FetchXML Builder also supports the no-lock hint and shows row counts per page. Use it to benchmark query performance before committing to production code.

12. Common Real-World Patterns

Pattern 1: Find records with no related records (NOT EXISTS)

<!-- Accounts with zero contacts -->
<fetch distinct="true">
  <entity name="account">
    <attribute name="name" />
    <attribute name="accountid" />
    <filter>
      <condition entityname="con" attribute="contactid" operator="null" />
    </filter>
    <link-entity name="contact" from="parentcustomerid" to="accountid"
                 link-type="outer" alias="con">
      <attribute name="contactid" />
    </link-entity>
  </entity>
</fetch>

Pattern 2: Get the latest record per group

<!-- Most recent case per customer -->
<fetch aggregate="true">
  <entity name="incident">
    <attribute name="customerid" groupby="true" alias="customerid" />
    <attribute name="createdon" aggregate="max" alias="latestcase" />
  </entity>
</fetch>

Pattern 3: Multi-condition OR across linked entities

<!-- Projects that are complete OR cancelled OR have stages with no tasks -->
<fetch distinct="true" no-lock="true">
  <entity name="project">
    <attribute name="name" />
    <attribute name="statuscode" />
    <filter type="and">
      <condition attribute="retailerid" operator="eq"
        value="abf8e77a-845b-f011-bec1-6045bda966da" />
      <filter type="or">
        <condition attribute="statuscode" operator="eq" value="290560003" />
        <condition attribute="statuscode" operator="eq" value="290560002" />
        <condition entityname="tsk" attribute="taskid" operator="null" />
      </filter>
    </filter>
    <link-entity name="stage" from="projectid" to="projectid"
                 link-type="outer" alias="stg">
      <link-entity name="task" from="stageid" to="stageid"
                   link-type="outer" alias="tsk">
        <attribute name="taskid" />
      </link-entity>
    </link-entity>
  </entity>
</fetch>

Pattern 4: Hierarchy query — tasks for a specific project via stages

<!-- All tasks under a project by traversing Project → Stage → Task -->
<fetch>
  <entity name="task">
    <attribute name="subject" />
    <attribute name="statuscode" />
    <attribute name="ownerid" />
    <link-entity name="stage" from="stageid" to="stageid"
                 link-type="inner" alias="stg">
      <attribute name="name" alias="stagename" />
      <link-entity name="project" from="projectid" to="projectid"
                   link-type="inner" alias="proj">
        <attribute name="name" alias="projectname" />
        <filter>
          <condition attribute="projectid" operator="eq"
            value="3fa85f64-5717-4562-b3fc-2c963f66afa6" />
        </filter>
      </link-entity>
    </link-entity>
    <filter>
      <condition attribute="statecode" operator="eq" value="0" />
    </filter>
  </entity>
</fetch>

Pattern 5: Active records modified in the last N days, owned by current user

<fetch top="200">
  <entity name="opportunity">
    <attribute name="name" />
    <attribute name="estimatedvalue" />
    <attribute name="modifiedon" />
    <filter>
      <condition attribute="statecode" operator="eq" value="0" />
      <condition attribute="ownerid" operator="eq-userid" />
      <condition attribute="modifiedon" operator="last-x-days" value="14" />
    </filter>
    <order attribute="modifiedon" descending="true" />
  </entity>
</fetch>

Pattern 6: Count of records per status with a join condition

<!-- Task count per project, only for active projects -->
<fetch aggregate="true">
  <entity name="task">
    <attribute name="taskid" aggregate="count" alias="taskcount" />
    <link-entity name="stage" from="stageid" to="stageid"
                 link-type="inner" alias="stg">
      <link-entity name="project" from="projectid" to="projectid"
                   link-type="inner" alias="proj">
        <attribute name="projectid" groupby="true" alias="projectid" />
        <attribute name="name" groupby="true" alias="projectname" />
        <filter>
          <condition attribute="statecode" operator="eq" value="0" />
        </filter>
      </link-entity>
    </link-entity>
  </entity>
</fetch>

13. Performance Tips

1. Never use a leading wildcard in LIKE

<!-- BAD: forces a full table scan -->
<condition attribute="name" operator="like" value="%contoso%" />

<!-- GOOD: index-seekable -->
<condition attribute="name" operator="like" value="contoso%" />
<!-- Or use begins-with explicitly -->
<condition attribute="name" operator="begins-with" value="contoso" />

2. Always use no-lock on read-only reporting queries

<fetch no-lock="true">
  <!-- Adds NOLOCK hint. Prevents blocking on busy tables.
       Only use for reporting — not for business-critical transactional reads. -->
</fetch>

3. Select only the columns you need

<!-- BAD: returns all columns -->
<entity name="contact">
  <!-- no attribute elements = SELECT * -->
</entity>

<!-- GOOD: return only what you use -->
<entity name="contact">
  <attribute name="fullname" />
  <attribute name="emailaddress1" />
</entity>

4. Filter as early as possible — push filters to the lowest join level

<!-- Filter on the join entity directly, not at the top -->
<link-entity name="account" from="accountid" to="parentcustomerid"
             link-type="inner" alias="acc">
  <filter>
    <!-- This runs during the join, not after -->
    <condition attribute="statecode" operator="eq" value="0" />
  </filter>
</link-entity>

5. Use top and paging — never retrieve all records speculatively

<!-- Always bound your queries in automation flows -->
<fetch top="5000" no-lock="true">
  <entity name="contact">
    <attribute name="fullname" />
  </entity>
</fetch>

6. Avoid deeply nested joins unless necessary

Each additional join level multiplies the SQL complexity. If you're going 4+ levels deep, consider whether a Dataverse Custom API or pre-computed rollup field could reduce query depth.

7. Use distinct only when you have to

distinct="true" forces a DISTINCT on the SQL side which means sorting the entire result set. Only use it when outer joins actually produce duplicates that matter for your output.

8. Index-backed fields filter faster

Dataverse automatically indexes primary keys, lookup fields, and some system fields like statecode, statuscode, ownerid, createdon, and modifiedon. Filtering on custom text fields without enabling a custom index will always be slower.

14. FetchXML vs OData

CapabilityFetchXMLOData ($filter)
Simple single-entity queries✅✅ (simpler syntax)
Multi-level joins✅❌ (expand limited to 1 level)
Outer joins / NOT EXISTS✅❌
Aggregate functions (sum, count, avg)✅❌
Group by with date parts✅❌
Dynamic date operators (last-x-days, this-week)✅❌
Cross-entity conditions (entityname=)✅❌
Paging with cookies✅✅ (@odata.nextLink)
Readable / writeable by non-developers❌ (verbose XML)✅ (concise URL params)
Works in Power Automate List Rows✅✅
Works in Canvas App formula barLimited✅
Direct URL testing (Postman, browser)⚠️ (URL-encode required)✅ (natural params)
Formatted value annotations✅ (via API header)✅ (via API header)
Tool support (FetchXML Builder)✅ (excellent)❌
NOLOCK hint✅❌

Decision rule

  • Use OData for simple filters on a single table, quick API tests, Canvas App formulas.
  • Use FetchXML for anything involving joins, aggregates, dynamic dates, NOT EXISTS, or paging beyond page 1 of a large dataset.
  • In plugins and C# code, prefer FetchXML — it's more predictable than LINQ and gives you full control over what the database executes.

Quick Reference Cheat Sheet

fetch attributes

AttributePurpose
top="N"Limit result count
distinct="true"Remove duplicate rows
no-lock="true"NOLOCK hint for read queries
aggregate="true"Enable aggregate mode
page="N"Page number
count="N"Page size (max 5000)
paging-cookie="..."Server-side cursor for efficient paging

Comparison operators

OperatorMeaning
eq / neEqual / Not equal
gt / ltGreater than / Less than
ge / leGreater or equal / Less or equal
like / not-likeString pattern match (% wildcard)
begins-withIndex-safe prefix match
in / not-inSet membership (use <value> children)
betweenRange (two <value> children)
null / not-nullIS NULL / IS NOT NULL

Dynamic date operators

OperatorNotes
today / yesterday / tomorrowNo value needed
this-week / this-month / this-yearNo value needed
last-seven-daysNo value needed
last-x-days / next-x-daysvalue="N" required
last-x-months / next-x-monthsvalue="N" required
on / on-or-before / on-or-aftervalue = YYYY-MM-DD

Aggregate functions

FunctionUsage
countRow count including nulls
countcolumnNon-null value count
sumNumeric sum
avgNumeric average
min / maxMin / Max value
groupby="true"Group by this column (no aggregate function)
dategroupingValues: day, week, month, quarter, year

link-entity

AttributeValues / Notes
nameLogical name of the entity to join
fromField on the child entity
toField on the parent entity
link-typeinner (default) or outer
aliasRequired for accessing attributes and cross-entity conditions

Cross-entity condition (NOT EXISTS)

<filter>
  <condition entityname="alias" attribute="pkfield" operator="null" />
</filter>
<link-entity ... link-type="outer" alias="alias">
  <attribute name="pkfield" />
</link-entity>

Skeleton query

<fetch top="500" distinct="false" no-lock="true">
  <entity name="entity_logical_name">
    <attribute name="field1" />
    <attribute name="field2" />
    <order attribute="field1" descending="false" />
    <filter type="and">
      <condition attribute="statecode" operator="eq" value="0" />
    </filter>
    <link-entity name="related_entity" from="fk_field" to="pk_field"
                 link-type="inner" alias="rel">
      <attribute name="name" alias="relatedname" />
    </link-entity>
  </entity>
</fetch>

Microsoft Power Pages & Portals Complete Guide

Microsoft Power Pages & Portals — Complete Guide

Power Pages Architecture · Liquid Templates · Web Roles · Authentication · Dataverse Integration · ALM · Scenarios · Cheat Sheet


Table of Contents

  1. Core Concepts — Basics
  2. Architecture & Components
  3. Authentication & Web Roles
  4. Liquid Templates & Customisation
  5. Dataverse Integration & Forms
  6. ALM, Security & Performance
  7. Scenario-Based Questions
  8. Cheat Sheet — Quick Reference

1. Core Concepts — Basics

What is Microsoft Power Pages and how did it evolve?

Microsoft Power Pages is a low-code platform for building secure, data-driven external-facing websites and portals. It is built on the Power Platform and uses Dataverse as its primary data store.

Evolution:

  • Dynamics 365 Portals (pre-2018): tightly coupled with Dynamics 365
  • Power Apps Portals (2018–2022): rebranded, part of Power Platform
  • Microsoft Power Pages (2022–present): standalone product with a modern design studio, enhanced security, and dedicated site management

What Power Pages enables:

  • External customer portals (case management, self-service)
  • Partner portals (deal registration, partner onboarding)
  • Employee self-service portals (expense claims, HR requests)
  • Community portals (forums, knowledge bases)
  • Government/citizen service portals
  • Event registration and management portals

Key positioning: Power Pages is the "external-facing" complement to model-driven Power Apps (internal). Where model-driven apps serve employees, Power Pages serves customers, partners, and citizens.


What is the difference between Power Pages and Power Apps model-driven apps?

Power Apps Model-Driven Apps:
→ Audience: internal users (employees)
→ Authentication: Entra ID (Azure AD) — requires M365/Azure licence
→ Interface: Unified Interface, Dataverse forms/views
→ Access: direct Dataverse access with security roles
→ Customisation: Power Apps designer, canvas controls
→ Licensing: per user (Power Apps licence)
→ Use for: CRM, ERP, internal workflows

Power Pages:
→ Audience: external users (customers, partners, citizens)
→ Authentication: identity providers (Entra B2B, Azure B2C,
                  Google, Facebook, local accounts, LinkedIn)
→ Interface: custom web pages, HTML/CSS/JS, Liquid templates
→ Access: filtered Dataverse data via table permissions + web roles
→ Customisation: Power Pages design studio, VS Code, Liquid/JS
→ Licensing: per page view or capacity (Power Pages licence)
→ Use for: customer portals, partner portals, citizen services

What are the Power Pages licence models?

Power Pages Authenticated Users:
→ Licensed per authenticated user per month
→ For portals where users must sign in to access content
→ Includes internal and external authenticated users
→ Covers: case submissions, account management, personalised content

Power Pages Anonymous Users (Capacity):
→ Licensed per page view (capacity units)
→ For public-facing portals where content is accessible without login
→ Anonymous users: 100 page views per capacity unit
→ Covers: public websites, event info, knowledge base browsing

Power Pages add-on:
→ Purchased as a standalone capacity add-on
→ Can be pooled across an environment

Included with Dynamics 365 licences:
→ D365 Customer Service Enterprise → 1 Power Pages production capacity
→ D365 Sales Enterprise → 1 Power Pages production capacity

What are the key portal templates available in Power Pages?

Template Use Case
Blank website Start from scratch with full control
Customer self-service Case creation, knowledge base, account management
Community Forums, idea submissions, community discussions
Partner Deal registration, partner onboarding, MDF claims
Employee self-service HR requests, expense claims, internal announcements
Modern Community Updated community template with modern UI
Starter layout 1-5 Pre-built page structures for rapid starts

2. Architecture & Components

What is the Power Pages architecture?

Power Pages Architecture:

External User (Browser)
        ↓ HTTPS
Power Pages Web Application (Azure-hosted)
        ↓
Caching Layer (Azure CDN / Portal Cache)
        ↓
Liquid Template Engine
        ↓
Power Pages Web API / FetchXML
        ↓
Dataverse (entity/table data)
        ↓
Connected Services:
  → Entra ID / Azure AD B2C (authentication)
  → Azure Blob Storage (file attachments)
  → SharePoint (document management)
  → Power Automate (workflow triggers)
  → Azure API Management (external API calls)

Key architectural facts:
→ Power Pages runs as a dedicated Azure Web App per environment
→ Each portal is associated with ONE Dataverse environment
→ Portal metadata (pages, content, web roles) stored in Dataverse tables
→ Content served via Liquid templates + static assets (CSS, JS, images)
→ Changes in Power Pages design studio write to Dataverse metadata tables

What are the core Power Pages components?

Webpages:
→ The URL-addressable pages of the portal
→ Each webpage has: name, partial URL, parent page, web template
→ Hierarchy: Home → About → Services → Contact
→ Published/Draft state: draft pages not visible to portal visitors

Web Templates:
→ Liquid + HTML defining how a page renders
→ Assigned to webpages
→ Can include: HTML, Liquid tags, JavaScript, Bootstrap CSS classes
→ Reusable across multiple pages

Content Snippets:
→ Small reusable pieces of text/HTML
→ Managed separately from page content
→ Used for: site name, footer text, error messages, shared labels
→ Editable by portal admins without editing the web template

Entity (Table) Forms:
→ Dataverse forms surfaced on the portal
→ Create, edit, or read-only modes
→ Linked to Dataverse table + form definition
→ Control: which columns visible, required fields, validation

Entity (Table) Lists:
→ Dataverse views surfaced as lists/grids on portal
→ Filter, sort, and paginate Dataverse records
→ Column display, search, linked forms for detail view
→ Supports: OData feeds, download as Excel/CSV

Web Files:
→ Static assets stored in Dataverse and served by the portal
→ Images, CSS overrides, JavaScript files, PDF downloads
→ Managed via Portal Management app or design studio

Site Settings:
→ Key-value configuration pairs controlling portal behaviour
→ Examples: Authentication/Registration settings, search settings,
            file upload limits, header/footer behaviour
→ Critical settings control authentication providers, email configs

What is the Portal Management app?

The Portal Management app is a model-driven Power App that provides full configuration access to all portal components — webpages, web templates, web roles, table permissions, entity forms, entity lists, site settings, and more.

When to use Portal Management app vs Design Studio:

Design Studio (Power Pages studio):
→ Visual, low-code experience
→ Page layout, sections, components drag-and-drop
→ Form/list configuration
→ Navigation management
→ Theme and styling
→ Best for: makers and page builders

Portal Management App:
→ Full access to all portal metadata tables in Dataverse
→ Advanced configuration not available in design studio
→ Web role and table permission configuration (complex setups)
→ Site settings configuration
→ Web template source code editing
→ Page hierarchy management
→ Authentication provider configuration
→ Best for: advanced developers and administrators

3. Authentication & Web Roles

What authentication providers does Power Pages support?

Supported identity providers:

Microsoft Entra ID:
→ Entra ID (Azure AD) — for employees and internal users
→ Entra External ID (B2B) — for partner organisations
→ Azure AD B2C — for consumer identities with custom flows
→ Protocol: OpenID Connect / OAuth 2.0

Social providers:
→ Microsoft Account (personal)
→ Google
→ Facebook
→ LinkedIn
→ Twitter/X (via custom OAuth)

Enterprise providers:
→ Any SAML 2.0 provider
→ Any OpenID Connect provider
→ WS-Federation

Local authentication:
→ Email/password accounts managed within Power Pages
→ Email confirmation, password reset flows included
→ NOT recommended for production — use federated IdPs

Multi-provider configuration:
→ A single portal can support multiple providers simultaneously
→ User sees options: "Sign in with Google" + "Sign in with Entra ID"
→ Contact matching: link sign-ins from different providers to same Contact record

What are Web Roles and how do they control access?

Web Roles are the primary access control mechanism in Power Pages — they determine what authenticated users can see and do on the portal.

Web Role structure:
Name: "Customer Standard"
Users: portal contacts assigned this role
Permissions: table permissions linked to this role

Special web roles:
Authenticated Users:  automatically assigned to ALL signed-in users
Anonymous Users:      automatically assigned to ALL non-signed-in users

Custom web roles examples:
→ Customer Basic: read own cases + create new cases
→ Customer Premium: read all cases for their account + create
→ Partner Manager: manage all partner accounts and opportunities
→ Portal Admin: manage portal content (pages, content snippets)
→ Knowledge Manager: publish/unpublish knowledge articles

Assigning web roles:
→ Manually: Portal Management app → Contacts → assign web role
→ Automatically: Power Automate flow → when user registers, assign role
→ Via invitation: send role-specific invitation links

Web Role + Table Permissions (together = access control):
Web Role defines WHO
Table Permission defines WHAT they can do to WHICH data

What are Table Permissions and how do they work?

Table Permissions (formerly Entity Permissions) define what authenticated or anonymous users can do with Dataverse data on the portal.

Table Permission configuration:
Table:      which Dataverse table (e.g., incident/Case)
Access type: Global, Contact, Account, Parent, Self
Privileges: Create, Read, Write, Delete, Append, AppendTo
Web Roles:  which web roles this permission applies to

Access types:
Global:  can access ALL records in the table (admin-level)
Contact: can only access records where Contact = logged-in user
Account: can access records belonging to the same Account
Parent:  can access child records via a parent relationship
Self:    Contact record can only access their own Contact record

Example — Customer case portal:
Table: incident (Case)
Access type: Contact (customerid = logged-in Contact)
Privileges: Create, Read, Write
Web Roles: [Authenticated Users]
→ Each user sees and edits ONLY their own cases

Example — Account manager portal:
Table: incident (Case)
Access type: Account (customerid.parentcustomerid = user's Account)
Privileges: Read
Web Roles: [Account Manager]
→ Account managers see all cases for their account company

Table permission hierarchy (child permissions):
Parent table: Account (Access type: Contact)
  Child table: Contact (Access type: Parent, via accountid)
→ User can read their own account AND contacts in that account

Critical: Without table permissions, portal users have NO access to any Dataverse data. Table permissions are additive — a user gets the union of all permissions from all their assigned web roles.


What is the portal registration and profile management flow?

New user registration flow:
1. User visits portal → clicks "Sign in"
2. Selects identity provider (Entra ID, Google, local account)
3. Completes IdP authentication
4. First time: portal creates/matches a Contact record in Dataverse
   Contact matching: by email address (configurable)
5. Optional: registration page for additional profile data
   (name, company, preferences — writes to Contact record)
6. Default web roles assigned: "Authenticated Users"
7. Optional: email confirmation required before full access
8. Custom roles: assigned manually or via Power Automate flow

Invitation flow (pre-approved access):
1. Admin creates Invitation record in Dataverse
2. Sets: invitation type (single/group), assigned web roles, expiry
3. Portal sends invitation email with unique link
4. User clicks link → completes registration
5. Assigned web roles applied automatically on redemption
6. Use for: partner onboarding, controlled external access

4. Liquid Templates & Customisation

What is Liquid and how is it used in Power Pages?

Liquid is an open-source template language used in Power Pages to generate dynamic HTML content. It allows web templates to access portal data, user context, and Dataverse records.

{# Liquid template example — personalised greeting and case list #}

{% if user %}
  <h1>Welcome back, {{ user.fullname }}!</h1>
  <p>Your open cases:</p>

  {% fetchxml %}
    <fetch mapping="logical" count="10">
      <entity name="incident">
        <attribute name="title" />
        <attribute name="statecode" />
        <attribute name="createdon" />
        <filter>
          <condition attribute="customerid" operator="eq"
            value="{{ user.id }}" />
          <condition attribute="statecode" operator="eq" value="0" />
        </filter>
        <order attribute="createdon" descending="true" />
      </entity>
    </fetch>
  {% endfetchxml %}

  {% if results.entities.size > 0 %}
    <ul>
    {% for case in results.entities %}
      <li>{{ case.title }} — Created: {{ case.createdon | date: "%d %b %Y" }}</li>
    {% endfor %}
    </ul>
  {% else %}
    <p>You have no open cases.</p>
  {% endif %}

{% else %}
  <p>Please <a href="/signin">sign in</a> to view your cases.</p>
{% endif %}

What are the key Liquid objects available in Power Pages?

{# Core Liquid objects #}

user          → currently authenticated portal user (Contact record)
  {{ user.fullname }}           ← Contact's full name
  {{ user.emailaddress1 }}      ← Contact's email
  {{ user.id }}                 ← Contact's GUID
  {{ user.roles }}              ← collection of web roles

page          → current webpage being rendered
  {{ page.title }}
  {{ page.adx_partialurl }}
  {{ page.breadcrumbs }}

website       → the portal website object
  {{ website.name }}
  {{ website.adx_primarydomainname }}

request       → current HTTP request
  {{ request.url }}
  {{ request.params['search'] }}  ← query string parameter
  {{ request.cookies['name'] }}

settings      → site settings (key-value store)
  {{ settings['Authentication/Registration/Enabled'] }}

snippets      → content snippets
  {{ snippets['site/tagline'] }}

now           → current date/time
  {{ now | date: "%Y-%m-%d" }}

Liquid filters (transform values):
  {{ value | upcase }}          ← UPPERCASE
  {{ value | downcase }}        ← lowercase
  {{ value | truncate: 50 }}    ← truncate to 50 chars
  {{ value | date: "%d %b %Y" }} ← format date
  {{ value | escape }}          ← HTML escape (XSS prevention)
  {{ value | default: "N/A" }}  ← default if null

What is FetchXML in Power Pages and how do you use it?

FetchXML is Dataverse's XML-based query language used within Liquid templates to retrieve data from Dataverse tables.

{# FetchXML example — get top 5 knowledge articles by rating #}
{% fetchxml %}
  <fetch mapping="logical" count="5">
    <entity name="knowledgearticle">
      <attribute name="title" />
      <attribute name="description" />
      <attribute name="adx_averagerating" />
      <filter type="and">
        <condition attribute="statecode" operator="eq" value="3" />  <!-- Published -->
        <condition attribute="isinternal" operator="eq" value="0" /> <!-- External -->
      </filter>
      <order attribute="adx_averagerating" descending="true" />
    </entity>
  </fetch>
{% endfetchxml %}

{% for article in results.entities %}
  <div class="article-card">
    <h3>{{ article.title }}</h3>
    <p>{{ article.description | truncate: 150 }}</p>
    <span>Rating: {{ article.adx_averagerating | round: 1 }}/5</span>
  </div>
{% endfor %}

{# FetchXML with linked entities (JOIN) #}
{% fetchxml %}
  <fetch mapping="logical">
    <entity name="incident">
      <attribute name="title" />
      <attribute name="statecode" />
      <link-entity name="contact" from="contactid" to="customerid" alias="cust">
        <attribute name="fullname" alias="customername" />
      </link-entity>
      <filter>
        <condition attribute="ownerid" operator="eq-userid" />
      </filter>
    </entity>
  </fetch>
{% endfetchxml %}

What is the Power Pages Web API and when do you use it?

The Power Pages Web API provides a RESTful endpoint for reading and writing Dataverse data from client-side JavaScript — enabling rich interactive experiences without page reloads.

// Power Pages Web API examples

// GET — read records (table permissions enforced):
const response = await fetch(
  '/_api/incidents?$select=title,statecode,createdon&$filter=statecode eq 0',
  {
    headers: {
      '__RequestVerificationToken': document.querySelector(
        'input[name="__RequestVerificationToken"]').value
    }
  }
);
const data = await response.json();
data.value.forEach(c => console.log(c.title));

// POST — create a record:
await fetch('/_api/incidents', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    '__RequestVerificationToken': getToken()
  },
  body: JSON.stringify({
    title: 'New Support Request',
    description: 'Issue description here',
    'customerid_contact@odata.bind': `/contacts(${userId})`
  })
});

// PATCH — update a record:
await fetch(`/_api/incidents(${caseId})`, {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    '__RequestVerificationToken': getToken()
  },
  body: JSON.stringify({ description: 'Updated description' })
});

// Key rules:
// → Table permissions enforced — users can only access permitted records
// → Anti-forgery token required for POST/PATCH/DELETE (CSRF protection)
// → Columns must be enabled for Web API in table settings
// → Use $select to limit returned columns

5. Dataverse Integration & Forms

How do Entity Forms work in Power Pages?

Entity Forms (Table Forms) surface Dataverse forms directly on the portal, allowing portal users to create, edit, or view Dataverse records.

Entity Form configuration:
Name:         friendly name for the form
Table:        which Dataverse table (e.g., incident)
Form:         which Dataverse form definition to use
Mode:         Insert (create), Edit, ReadOnly
Record source: QueryString (ID from URL), Current Portal User,
               Fixed Record (always same record)

Entity Form metadata:
→ Additional settings overlaid on the Dataverse form
→ Control: redirect after submit, success message
→ Validation: custom JavaScript validation
→ On Success: URL redirect, show message, or load another form step

Multi-step forms (Form Steps):
→ Wizard-style multi-page forms
→ Each step = one Entity Form
→ Steps linked via Web Form (multi-step form container)
→ Progress indicator, back/next navigation
→ Data saved incrementally between steps
→ Final step submits all data

Example — 3-step case creation wizard:
Step 1: "What is the issue?" → capture Title + Description
Step 2: "Contact details" → capture Contact info
Step 3: "Review and submit" → display summary + submit

Entity List on form:
→ Related records displayed below the main form
→ e.g., Case form with related Notes list below

How do Entity Lists work in Power Pages?

Entity Lists (Table Lists) display Dataverse records as searchable, filterable lists on the portal.

Entity List configuration:
Table:        which Dataverse table
View:         which Dataverse saved view to use (filter + columns)
Page size:    number of records per page (default 10)
Search:       enable/disable search across listed columns
Filter:       metadata filters (filter by date, status, lookup)
Actions:      Create (link to Entity Form), View, Edit, Delete actions

OData feed:
→ Entity List can expose an OData endpoint
→ External apps can consume portal data via OData
→ URL: /[listname]/odata — returns JSON
→ Filtered by table permissions (user only sees permitted records)

Subgrids:
→ Entity List embedded as a subgrid within an Entity Form
→ e.g., Account page shows all Cases for that Account
→ Configured via Entity List with parent filter

Portal search integration:
→ Entity List results included in portal search
→ Requires search enabled on the entity list
→ Records indexed based on view columns

How does SharePoint document management integrate with Power Pages?

SharePoint integration setup:
1. Enable SharePoint integration in Dataverse (Power Platform Admin Centre)
2. Configure document location: Dataverse record → SharePoint folder
3. Portal displays document library on Entity Form

How it works:
→ User opens a Case record on the portal
→ Entity Form displays "Documents" section (if configured)
→ Documents stored in SharePoint (not Dataverse attachment)
→ Portal fetches document list via SharePoint integration
→ User can upload/download documents from the portal
→ Files stored in SharePoint → version history, large file support

Supported operations via portal:
→ View document list for a record
→ Upload new documents (stored in SharePoint)
→ Download documents
→ Delete documents (with appropriate permissions)

Limitations:
→ Requires SharePoint integration enabled at environment level
→ SharePoint must be in same M365 tenant
→ Anonymous users cannot access SharePoint-integrated documents

6. ALM, Security & Performance

How do you manage Application Lifecycle Management (ALM) for Power Pages?

Power Pages ALM approach:

1. Dataverse Solutions:
   → Package portal metadata in a Dataverse solution
   → Solution contains: webpages, web templates, entity forms/lists,
     table permissions, web roles, site settings, content snippets
   → Export as managed solution (for target environments)
   → Import into Test → UAT → Production

2. Power Platform CLI (pac cli):
   → pac pages upload / download — sync portal files to/from local disk
   → Local editing: edit Liquid templates, CSS, JS in VS Code
   → Upload changes back to Dataverse portal tables
   → pac solution export/import for full solution-based ALM

3. Source control (Git):
   → Export portal files with pac CLI → commit to Git
   → Enables: version history, PR review of template changes,
     branch-based development
   → CI/CD: GitHub Actions / Azure DevOps pipeline using pac CLI

4. Environment variables:
   → Site settings that differ between environments
   → Dev: dev.contoso.com, Prod: portal.contoso.com
   → Connection strings, API endpoints, feature flags

ALM workflow:
Developer → local edit → pac upload → Dev environment test
         → pac solution export → PR review
         → merge → automated pipeline → UAT → Production

What are the key security considerations for Power Pages?

Security layers:

1. Table permissions (data layer):
   → Always configure — no table permissions = no data access
   → Follow least privilege — start restrictive, open as needed
   → Test with different user types (anonymous, authenticated, roles)
   → Audit table permissions regularly

2. HTTPS (transport layer):
   → Always enforced — Power Pages provides SSL certificate automatically
   → Custom domain: upload your own SSL certificate or use Let's Encrypt

3. Content Security Policy (CSP):
   → Configure allowed script/style sources
   → Prevent XSS attacks via CSP headers
   → Site setting: HTTP/X-Content-Security-Policy

4. Anti-forgery tokens:
   → Required for all POST/PATCH/DELETE Web API calls
   → Prevents CSRF attacks
   → Built into Entity Forms automatically

5. Rate limiting:
   → Configure site settings to limit API call frequency per user
   → Prevents abuse of Web API endpoints

6. Bot protection:
   → Configure reCAPTCHA or Cloudflare Turnstile on registration + forms
   → Site setting: Recaptcha/Enabled, Recaptcha/PublicKey

7. IP restrictions:
   → Restrict portal access to specific IP ranges
   → Site setting: Maintenance Mode for controlled access

8. Authentication hardening:
   → Disable local authentication in production (use federated IdPs)
   → Configure session timeout (Site/SessionTimeoutEnabled)
   → Require email confirmation for registration

9. Content hiding vs Table Permissions:
   → Hiding a page in navigation does NOT secure the data
   → Table Permissions control actual data access
   → "Security through obscurity" is not security — always use table permissions

What are Power Pages performance best practices?

Caching:
→ Portal output caching: pages cached for 15 minutes by default
→ Clear cache: Power Pages studio → Sync (clears portal cache)
→ Partial caching: use {% cache %} tag for expensive Liquid blocks
→ Static files: served via CDN (Azure CDN) — always fast

FetchXML optimisation:
→ Use count attribute to limit records returned
→ Use $select equivalent — only fetch needed attributes
→ Avoid N+1 queries: use link-entity instead of nested loops
→ Index frequently filtered columns in Dataverse

Entity List performance:
→ Set reasonable page size (10-25 records)
→ Ensure Dataverse view has appropriate filters (don't load all records)
→ Index columns used in view filters

Web API performance:
→ Always use $select to limit returned columns
→ Use $top to limit result count
→ Avoid calls on page load for non-critical data — lazy load

Liquid template performance:
→ Avoid complex Liquid in high-traffic page templates
→ Use content snippets for frequently changing small content
→ Minimise FetchXML calls per page (each = a Dataverse query)

CDN and static assets:
→ Use web files for CSS/JS (served via CDN)
→ Compress images before uploading as web files
→ Minimise inline CSS/JS in web templates

7. Scenario-Based Questions

Scenario: Design a customer self-service portal for a financial services company.

Requirements: Customers log in, view their accounts, create service requests, track case status, and download statements.

  1. Authentication: Azure AD B2C with custom branding. Email/password + optional social login. MFA enforced via B2C policy. Contact matching by email.

  2. Portal structure:

    • Home (public) — product information, login CTA
    • My Account (authenticated) — account details, balance, contact info
    • Service Requests (authenticated) — create new case, view case history
    • Documents (authenticated) — download statements from SharePoint
    • Knowledge Base (public) — FAQ, guides
  3. Web Roles:

    • Authenticated Customers — read own account, create cases, view own cases
    • Premium Customers — all above + priority case routing flag
  4. Table Permissions:

    • Contact: Self access → read own Contact record
    • Account: Contact access → read their associated Account
    • Incident (Case): Contact access → create + read own cases
    • KnowledgeArticle: Global → read published external articles
  5. Forms:

    • Case creation: multi-step form (Issue type → Details → Confirmation)
    • Case detail: read-only view with status timeline
    • Profile update: edit mode for Contact email/phone
  6. Security: reCAPTCHA on registration and case creation. CSP headers configured. Disable local authentication — B2C only.

  7. ALM: solution-based deployment. Dev → UAT → Production pipeline via GitHub Actions + pac CLI.


Scenario: How do you restrict a portal page to users with a specific web role?

Three approaches (all needed together for full security):

1. Page visibility in navigation:
   → Webpage → Publishing State: Published
   → Web Role restriction on webpage: only visible to "Premium Customers" role
   → Page does NOT appear in navigation for other users
   → This is NOT security — page URL may still be accessible directly

2. Webpage Access Control (Entity Permission on Webpage):
   → Associate the webpage with a specific web role
   → Users without the role see: 403 Forbidden or redirect to sign-in
   → Configured via: Webpage → Web Roles (many-to-many relationship)
   → This IS the correct security control for pages

3. Table Permissions (data layer):
   → Even if user accesses the page, data is only visible if
     table permissions allow it
   → Defence in depth: page access + data access both restricted

Example:
Webpage "Premium Dashboard":
  Web Roles: [Premium Customers]    ← page access
  Entity List on page:
    Table: financial_statement
    Access: Account
    Web Roles: [Premium Customers]  ← data access

Result: non-Premium users cannot see the page AND cannot access
the data even if they somehow reach the URL.

Scenario: How do you implement a multi-language portal?

Power Pages multi-language support:

1. Enable languages:
   → Portal Management app → Website → Supported Languages
   → Add languages: en-US (default), fr-FR, de-DE, es-ES
   → Install language packs for each language

2. Content localisation:
   → Webpages: create a localised copy per language
     Home (en-US) → Home (fr-FR) → Home (de-DE)
   → Content Snippets: create per-language versions
   → Entity Form labels: localised via Dataverse column label translations

3. Language switcher:
   → Liquid template: loop through supported languages
   {% for lang in website.supported_languages %}
     <a href="{{ request.url | set_language: lang.code }}">
       {{ lang.name }}
     </a>
   {% endfor %}

4. URL structure:
   → Language in URL: /en-US/home, /fr-FR/home
   → Or: subdomain — en.portal.contoso.com, fr.portal.contoso.com

5. Right-to-left (RTL) support:
   → Enable RTL for Arabic (ar), Hebrew (he)
   → Configure Bootstrap RTL CSS for RTL languages

6. Browser language auto-detection:
   → Site setting: Globalization/AutoDetectLanguage = true
   → Portal automatically redirects to preferred language based on
     browser Accept-Language header

Scenario: How do you troubleshoot a portal page that shows no data despite the user being authenticated?

Diagnostic checklist:

1. Check if user is authenticated:
   Liquid: {% if user %}...{% endif %}
   → If user object is empty: authentication not completing → check IdP config

2. Check Web Role assignment:
   → Portal Management app → Contacts → find user → Web Roles
   → Verify "Authenticated Users" role is assigned (should be automatic)
   → Verify custom role is assigned if page requires specific role

3. Check Table Permissions:
   → Portal Management app → Table Permissions → find permission for the table
   → Verify: correct table, correct access type, correct web roles, correct privileges
   → Permission must have Read privilege for data to display

4. Check if Entity List/Form has correct table:
   → Verify Entity List points to the correct Dataverse table
   → Verify the Dataverse view used exists and returns data in model-driven app

5. Check FetchXML (if custom Liquid):
   → Add debug output: {{ results | dump }}
   → Check if FetchXML returns data in XrmToolBox FetchXML Builder
   → Verify filter conditions match actual data values

6. Check Portal Cache:
   → Design Studio → Sync (clears portal output cache)
   → Table permission changes require cache clear to take effect

7. Check browser console:
   → Web API errors appear in browser console
   → 403: table permission blocking access
   → 401: user not authenticated for this API call
   → 500: server error — check portal diagnostic logs

8. Enable portal diagnostics:
   → Append ?enableportaldiagnostics=true to URL
   → Shows diagnostic information for admins

8. Cheat Sheet — Quick Reference

Power Pages Component Summary

Webpages:          URL-addressable pages, publish/draft state
Web Templates:     Liquid + HTML templates assigned to pages
Content Snippets:  Reusable text/HTML fragments
Entity Forms:      Dataverse forms (create/edit/read)
Entity Lists:      Dataverse views displayed as lists
Web Roles:         Access groups controlling what users can see/do
Table Permissions: Data access control (who reads/writes which records)
Web Files:         Static assets (CSS, JS, images, PDFs)
Site Settings:     Key-value portal configuration

Table Permission Access Types

Global:  ALL records in the table (use sparingly — admin-level)
Contact: records WHERE contact lookup = logged-in user
Account: records WHERE account lookup = user's parent account
Parent:  child records accessible via parent record permission
Self:    Contact record = user's own Contact only

Privileges: Create | Read | Write | Delete | Append | AppendTo

Example combinations:
Cases (own): Table=incident, Access=Contact, Priv=Create+Read+Write
Cases (account): Table=incident, Access=Account, Priv=Read
Articles: Table=knowledgearticle, Access=Global, Priv=Read
Own profile: Table=contact, Access=Self, Priv=Read+Write

Key Liquid Objects

{{ user }}              → authenticated Contact record
{{ user.fullname }}     → Contact display name
{{ user.id }}           → Contact GUID
{{ user.roles }}        → collection of web role names
{{ page.title }}        → current page title
{{ page.adx_partialurl }} → current page URL segment
{{ request.url }}       → full current URL
{{ request.params['key'] }} → query string value
{{ settings['key'] }}   → site setting value
{{ snippets['name'] }}  → content snippet value
{{ now }}               → current datetime

Authentication Provider Site Settings

Key site settings for authentication:
Authentication/Registration/Enabled = true/false
Authentication/Registration/RequiresConfirmation = true/false
Authentication/Registration/LocalLoginEnabled = false (production)
Authentication/Registration/ExternalLoginEnabled = true

Azure AD:
Authentication/OpenIdConnect/AzureAD/Authority = https://login.microsoftonline.com/{tenantId}/v2.0
Authentication/OpenIdConnect/AzureAD/ClientId = {appId}
Authentication/OpenIdConnect/AzureAD/ClientSecret = {secret}

Google:
Authentication/OpenIdConnect/Google/Authority = https://accounts.google.com
Authentication/OpenIdConnect/Google/ClientId = {clientId}

Session:
Site/SessionTimeoutEnabled = true
Site/SessionTimeoutMinutes = 30

ALM Quick Reference

pac CLI commands for Power Pages:
pac auth create --url https://contoso.crm.dynamics.com
pac pages upload --path ./portal-root --deploymentProfile dev
pac pages download --path ./portal-root --environment contoso-dev
pac solution export --name PortalSolution --path ./solutions --managed
pac solution import --path ./solutions/PortalSolution_managed.zip

Environment-specific settings:
→ Use Dataverse environment variables for site settings
→ Dev/Test/Prod have different values for:
   Portal URL, API endpoints, reCAPTCHA keys, email settings

GitHub Actions pipeline example:
1. pac auth create (service principal)
2. pac solution import (deploy portal solution)
3. pac pages upload (deploy template/file changes)
4. Clear portal cache (POST to /_services/about/clearcache)

Top 10 Tips

  1. Table Permissions are mandatory — without them, authenticated users see NO Dataverse data. The most common portal configuration mistake is forgetting to set table permissions. Start here in any debugging scenario.

  2. Web Roles control page access, Table Permissions control data access — both are needed for full security. A page visible to the wrong role but with correct table permissions still shows no data. A page hidden but without table permissions is security-through-obscurity only.

  3. Power Pages vs model-driven apps — Power Pages serves external users (customers/partners), model-driven serves internal users (employees). They use the same Dataverse but different access mechanisms (table permissions + web roles vs Dataverse security roles).

  4. Authenticated Users web role is automatic — every signed-in user gets this role automatically. Design your table permissions baseline around this role. Custom roles are additive on top.

  5. FetchXML is the query language — know the basic structure: fetch → entity → attribute + filter + order. Use link-entity for joins. Use alias for linked entity attributes. This comes up in every advanced Power Pages technical round.

  6. Portal cache must be cleared after table permission changes — table permission changes don't take effect immediately. Clear cache in Design Studio or sync via pac CLI. A frustrating "why isn't this working?" scenario solved by a cache clear.

  7. Anti-forgery token required for Web API — POST/PATCH/DELETE calls without __RequestVerificationToken return 400 Bad Request. Always retrieve this token from the hidden input on the page and include in API request headers.

  8. pac CLI is the ALM tool — pac pages upload/download syncs portal files to/from disk for source control. Know the basic commands. Every enterprise Power Pages project uses this for CI/CD.

  9. Multi-step forms = Web Form + Form Steps — not just multiple Entity Forms. The Web Form container manages session state between steps. Each step is a separate Entity Form but data persists until final submit.

  10. Disable local authentication in production — local accounts (email/password managed by portal) should be disabled for enterprise portals. Use federated identity providers (Entra ID, Azure AD B2C) for proper security, audit trails, and MFA support.



Friday, April 24, 2026

Power Pages Complete Guide

 

Power Pages Complete Guide

From Basics to Advanced — Web API, Auth, Liquid, Scenarios & Cheat Sheet


Table of Contents

  1. Basic Questions
  2. Security & Access
  3. Data & Forms
  4. Customization & Code
  5. Advanced — Web API
  6. Advanced — Custom Authentication
  7. Advanced — Performance Tuning
  8. Advanced — ALM & CI/CD
  9. Scenario-Based — Troubleshooting
  10. Scenario-Based — Web API Debugging
  11. Scenario-Based — Portal Design
  12. Scenario-Based — Security Scenarios
  13. Liquid Templating — Advanced Patterns
  14. Liquid Templating — Web Templates
  15. Liquid Templating — Performance
  16. Liquid Templating — Gotchas & Traps
  17. Liquid Cheat Sheet

1. Basic Questions

What is Microsoft Power Pages?

Power Pages is a low-code SaaS platform for building secure, data-driven external-facing websites. It is part of the Microsoft Power Platform and connects to Dataverse as its backend data store.


What are the main components of a Power Pages site?

Pages, Web templates, Table lists, Table forms, Web roles, Site settings, Content snippets, and Entity permissions. Together they control layout, data access, and security.


What is Dataverse and why is it important in Power Pages?

Dataverse is the cloud data platform that backs Power Pages. All data rendered on the portal — tables, forms, lists — is stored and retrieved from Dataverse. Role-based security on Dataverse tables controls what portal users can see or edit.


What is the difference between Power Pages and Power Apps portals?

Power Pages is the rebranded and evolved version of Power Apps portals (announced 2022). It adds a dedicated design studio, Pages workspace, Copilot features, and a new standalone licensing model, while retaining all portal functionality.


What tools are used to build Power Pages sites?

Three main tools:

  1. Design Studio — WYSIWYG low-code editor in browser.
  2. Power Pages Management App — advanced configuration in model-driven app.
  3. Power Platform CLI / VS Code extension — code-first development and ALM.

What are Content Snippets?

Content Snippets are reusable text/HTML fragments managed in the portal back-end. They allow non-developers to edit labels, messages, and static text across the site without touching templates. Referenced in Liquid as {% snippets['snippet_name'] %}.


What are Site Settings and give two examples?

Site Settings are key-value configuration pairs that control portal behavior. Examples:

  • Authentication/Registration/Enabled — enables/disables user self-registration.
  • Webapi/<tablename>/enabled — enables the Web API for a specific table.

2. Security & Access

What are Web Roles in Power Pages?

Web Roles group portal users and control what they can access. Every authenticated user is assigned one or more web roles. Two built-in roles exist: Authenticated Users (all logged-in users) and Anonymous Users (not logged in).


What are Table Permissions and why are they critical?

Table Permissions define CRUD access on Dataverse tables for a specific web role. Without proper table permissions, users see no data even if the page is accessible. They support scopes: Global, Contact, Account, Parent, and Self.


What is the difference between Page-level security and Table Permissions?

  • Page-level security controls who can navigate to a page (via web role on the page).
  • Table permissions control what data a user can read/write from Dataverse.

Both layers must be configured for secure, functional data access.


What authentication providers does Power Pages support?

Azure AD (Entra ID), Azure AD B2C, SAML 2.0, OpenID Connect, OAuth 2.0 providers (Google, Facebook, LinkedIn, Twitter), and local identity (email + password). Multiple providers can be enabled simultaneously.


3. Data & Forms

What is a Table List (Entity List)?

A Table List renders rows from a Dataverse table on a page as a grid or gallery. It supports filtering, sorting, searching, and actions like create/edit/delete (subject to table permissions).


What is a Table Form (Basic Form) vs a Multi-step Form (Advanced Form)?

  • Table Form (Basic Form): Single-step form bound to one Dataverse form. Used for simple create/edit/read operations.
  • Multi-step Form (Advanced Form): Wizard-style form spanning multiple steps/tables. Supports conditional branching and sequential data capture.

How does Power Pages handle file attachments in forms?

Files are stored as Notes (annotations) attached to the Dataverse record, or via SharePoint integration. You must enable the attachment option on the form and grant the Notes table permissions to the relevant web role.


4. Customization & Code

What is Liquid in Power Pages?

Liquid is an open-source templating language used in Power Pages web templates and content snippets to dynamically render data, conditional content, and loops. It allows server-side access to Dataverse data without custom code.

Example:

{% entitylist id: page.adx_entitylist.id %}

How can you integrate Power Automate with Power Pages?

Power Automate flows can be triggered from portal forms using the Power Pages connector (cloud flows) or by using the Web API. Common use case: send email notifications when a form is submitted.


What is the Power Pages Web API?

A RESTful API that enables client-side JavaScript to perform CRUD operations on Dataverse tables directly from the portal without a page postback. It respects table permissions. Endpoint pattern: /_api/<tablename>.


How do you deploy or migrate a Power Pages site between environments?

Using Power Platform solutions — package the portal components into a solution and deploy via Solution import, or use the Power Platform CLI commands:

pac paportal download
pac paportal upload

with source control for CI/CD.


How does caching work in Power Pages?

Power Pages caches portal metadata (templates, settings, entity lists) server-side. Changes made in the management app may not reflect immediately. Clear cache via:

<siteurl>/_services/about

or by restarting the portal from the Power Platform admin center.


5. Advanced — Web API

How do you enable the Web API for a specific table?

Create two Site Settings:

  • Webapi/<tablename>/enabled → true
  • Webapi/<tablename>/fields → comma-separated column names (or * for all)

Then grant the appropriate Table Permission (Read/Write/Create/Delete) to the web role. Without both, the API returns 403.


What HTTP methods does the Power Pages Web API support?

  • GET — read records (supports $filter, $select, $orderby, $top, $expand)
  • POST — create a record
  • PATCH — update an existing record
  • DELETE — delete a record

Base URL pattern: /_api/<tablename>(guid)

Note: PUT is not supported.


How is CSRF protection handled in Web API calls?

All write operations (POST, PATCH, DELETE) require an anti-forgery token sent in the request header as __RequestVerificationToken. Retrieve it via:

$('[name=__RequestVerificationToken]').val()
// or from:
/_api/antiforgery/token

Tip: Forgetting the anti-forgery token is the #1 reason Web API writes fail silently in demos.


How do you filter and expand related records using the Web API?

/_api/contacts?$filter=firstname eq 'John'
&$select=fullname,emailaddress1
&$expand=parentcustomerid_account($select=name)
&$top=10

Expanding requires Table Permissions on the related table too.


Can anonymous users call the Power Pages Web API?

Yes, but only if Table Permissions with the required privilege are assigned to the Anonymous Users web role. By default no permissions exist for anonymous users, so all API calls return 403.


What are the limitations of the Power Pages Web API compared to Dataverse Web API?

  • No batch requests ($batch not supported)
  • No executing actions or functions directly
  • No metadata endpoint (/$metadata)
  • Field access restricted to columns listed in the fields site setting
  • Rate limits apply (portal-level throttling)
  • No server-side pagination tokens — use $top and $skip

6. Advanced — Custom Authentication

How do you configure Azure AD B2C as an identity provider?

Register an app in Azure AD B2C, create user flows (sign-up/sign-in). Then set Site Settings:

  • Authentication/OpenIdConnect/AzureADB2C/Authority → B2C tenant URL
  • Authentication/OpenIdConnect/AzureADB2C/ClientId → app client ID
  • Authentication/OpenIdConnect/AzureADB2C/RedirectUri → portal callback URL

Add the redirect URI to the B2C app registration's allowed redirect URIs.


What is contact mapping and why is it important?

When a user authenticates, Power Pages maps the external identity to a Dataverse Contact record. This mapping determines what data the user can access. Without a matching Contact, the user may authenticate but have no portal profile or table-permission scope.


How do you implement invitation-based registration?

Create an Invitation record in Dataverse linked to the Contact, set expiry and type (single/group). Send the invitation link to the user. When they redeem it, a portal identity is created and linked to the Contact.


What is the difference between local identity and external identity providers?

  • Local identity: credentials stored within Power Pages using ASP.NET Identity. Supports email confirmation, password reset, and lockout.
  • External providers (Azure AD, B2C, Google, etc.): authentication is delegated via OAuth/OIDC. Power Pages only receives a token and maps it to a Contact — it never stores passwords.

How do you implement MFA on Power Pages?

Power Pages itself does not provide MFA — delegate auth to an external provider that supports MFA (Azure AD Conditional Access or Azure AD B2C MFA user flows). The portal trusts the token issued after MFA is satisfied.

Tip: The correct answer is always — push MFA to the identity provider, never try to build it inside the portal.


7. Advanced — Performance Tuning

What causes slow portal page loads and how do you diagnose them?

Common causes: excessive Liquid Dataverse queries, large unbounded table lists, unoptimised fetchXML, heavy JavaScript bundles, cache misses.

Diagnose using:

  • Browser DevTools Network tab
  • /_services/about — check cache state
  • Azure Application Insights (if configured)
  • Power Pages telemetry in the admin centre

How does server-side caching work and how do you control it?

Power Pages caches portal metadata in memory. To clear:

  • Navigate to <site>/_services/about → click Clear cache
  • Restart the portal from Power Platform admin centre

For live data, use the Web API client-side instead of server-side Liquid queries.


How do you optimise a slow Table List?

  • Enable pagination — set a sensible page size (25–50)
  • Use $select to fetch only required columns
  • Add filters so the Dataverse query is targeted
  • Add Dataverse indexes on columns used in filter/sort
  • Avoid expanding many lookups in a single list view
  • Consider replacing with a client-side Web API call + JS rendering

How do you implement CDN for Power Pages assets?

Power Pages has a built-in Azure CDN integration. Enable it in the Power Platform admin centre under portal settings. Once enabled, static assets (CSS, JS, images in web files) are served from the CDN edge. Custom domain + SSL is required before CDN can be enabled.


8. Advanced — ALM & CI/CD

How do you use Power Platform CLI to manage Power Pages in source control?

# Download site content
pac paportal download --path ./portal --webSiteId <guid>

# Upload to target environment
pac paportal upload --path ./portal

# Auth with service principal
pac auth create

How do you handle environment-specific configuration during deployment?

Use deployment settings files (deploymentSettings.json) with the Power Platform Build Tools or CLI. These files override Site Setting values per environment (dev/test/prod) at import time — keeping secrets and environment URLs out of source control.


How do you automate Power Pages deployment in Azure DevOps?

  1. Power Platform Tool Installer
  2. Power Platform Export Solution (from source env)
  3. Commit to repo
  4. Power Platform Import Solution (to target env)
  5. pac paportal upload step via CLI task for portal content files

Use a service principal with System Administrator role on the target environment.


9. Scenario-Based — Troubleshooting

Scenario: User logs in but sees no data in the Table List. No error shown.

Diagnose in order:

  1. Confirm the user's web role via the Contact record in Portal Management App.
  2. Check Table Permissions for that web role — verify Read privilege is granted.
  3. Check the scope. If scope is Contact or Account, the parent relationship record must exist and be linked correctly.
  4. Clear the portal cache at /_services/about.
  5. Check if the Table List has a filter that excludes records for this user.

Root cause 90% of the time: Missing Table Permission or wrong scope.


Scenario: Form submission silently fails — page reloads, no record created.

  1. Open DevTools → Network tab → re-submit. Look for a non-200 POST response.
  2. Check Table Permissions — web role must have Create privilege.
  3. Check if required Dataverse fields are missing from the portal form.
  4. Verify form mode is set to Insert, not Edit or ReadOnly.
  5. Check console for JS errors blocking submission.
  6. Check for Dataverse business rules blocking create.

Tip: Add ?debug to the URL to surface additional error detail.


Scenario: After deploying a solution, portal still shows old content.

This is a portal metadata cache issue. Fix:

  1. Navigate to <portalurl>/_services/about
  2. Click Clear cache
  3. If unresponsive, restart from Power Platform Admin Centre → Environments → Resources → Portals → Restart
  4. For CI/CD, add a cache-clear step post-deployment.

Scenario: User can edit records they should not be able to.

  1. Check if the user has a second web role with Write/Edit permission.
  2. Check Table Permissions on Authenticated Users built-in role — if Write is there, every logged-in user inherits it.
  3. Confirm scope — Global scope incorrectly set allows editing all records.

Warning: Never rely solely on hiding the Edit button. Always enforce at the Table Permission level — UI controls are bypassable via direct URL.


10. Scenario-Based — Web API Debugging

Scenario: Web API POST returns 403 Forbidden.

  1. Confirm Site Setting Webapi/<tablename>/enabled is true (exact logical name, case-sensitive).
  2. Confirm Webapi/<tablename>/fields lists the columns being written, or is *.
  3. Confirm the user's web role has a Table Permission with Create privilege.
  4. Check request headers — POST/PATCH/DELETE require __RequestVerificationToken. Missing token = 403.
  5. Clear portal cache.
  6. Test with Global-scope permission temporarily to isolate scope vs. permission issues.

Key distinction: 403 on GET = missing Read permission. 403 on POST = missing Create permission OR missing anti-forgery token.


Scenario: Web API GET returns empty array but records exist in Dataverse.

Empty array (not 403) means the API is enabled and Read permission exists — but scope is filtering records out.

  1. Check Table Permission scope. Contact scope only returns records linked to the logged-in Contact.
  2. Verify the relationship column is populated on the records.
  3. Try Global scope temporarily in dev to confirm.

Scenario: PATCH request succeeds but one field is always ignored.

  1. Check Webapi/<tablename>/fields — if it's a list (not *), confirm the field is included.
  2. Check Dataverse Field Security Profiles — update silently dropped if field-level security restricts it.
  3. Check for Dataverse business rules or plugins resetting the field.
  4. Verify you're sending the correct logical field name (lowercase, publisher prefix).

Scenario: Works in dev, returns 403 in production.

  1. Compare Site Settings between environments — enabled and fields settings may not have been deployed to prod.
  2. Compare Table Permissions — if not in the solution, they need to be recreated in prod.
  3. Check web roles — the Contact record in prod may not have the same web role assigned.
  4. Clear cache in prod.

Root cause: Environment parity issues. Always include Table Permissions and Site Settings in your solution or deployment checklist.


11. Scenario-Based — Portal Design

Design: Multi-role portal — customers see own cases, partners see account cases, admins see all.

Create three web roles with Table Permissions on the Case table:

Role Scope Privilege
Customer Contact (customerid) Read
Partner Account (customerid) Read
Admin Global Full CRUD

Use a single Table List on one page — the data returned automatically filters by scope. No code needed for the filtering logic.

Key insight: One page + one table list + three scopes = three different views automatically.


Design: Multi-step onboarding form spanning three tables.

Use a Multi-step Form (Advanced Form):

  1. Step 1: Account table, mode = Insert
  2. Step 2: Contact table, mode = Insert, auto-populate Account lookup from step 1
  3. Step 3: Custom subscription table, mode = Insert, pre-populate Account lookup via Referenced Entity
  4. Grant Table Permission Create on all three tables for the relevant web role.

Design: Real-time data dashboard (records reflect within seconds without page reload).

Server-side Liquid/Table Lists cannot do real-time — they render at page load only.

Use the Power Pages Web API client-side:

setInterval(async () => {
  const res = await fetch('/_api/cr123_orders?$filter=...');
  const data = await res.json();
  // update DOM with data.value
}, 5000);

Tip: Polling every 5–10 seconds handles most "real-time" business requirements without additional infrastructure.


Design: Anonymous lead capture form — no login, no data visible.

  1. Basic Table Form on Lead table, mode = Insert, no authentication required.
  2. Grant Anonymous Users web role a Table Permission with only Create privilege — no Read, Write, or Delete.
  3. No Table List on any anonymous page.
  4. Add CAPTCHA via custom Liquid/JS integration (Google reCAPTCHA).
  5. Set Authentication/Registration/Enabled to false if no self-registration needed.

12. Scenario-Based — Security Scenarios

Scenario: Pen test finds users can access other users' records by manipulating the GUID in the URL.

Fix: Change Table Permission scope from Global to Contact.

With Contact scope, fetching a record not linked to the user's Contact returns 403 — even with the correct GUID.

Warning: Global Read scope is the single most common security misconfiguration in Power Pages portals. Default to Contact or Account scope unless Global is explicitly required.


Scenario: Prevent portal from being used as open relay to spam Dataverse.

  1. Ensure only authenticated users have Create permission.
  2. Add CAPTCHA on public forms.
  3. Use Power Automate to detect unusual create volume.
  4. Dataverse API limits apply — 6000 requests per 5 minutes per user.
  5. Route public form submissions through a Power Automate HTTP trigger for extra validation.

Scenario: Company A must never see Company B data (multi-tenant portal).

  1. Ensure every Contact is linked to the correct parent Account.
  2. Ensure all data records have a lookup to the Account.
  3. Set all Table Permissions to Account scope.
  4. Use Parent scope for child records (e.g., Order Lines linked to Orders).

Tip: Account scope is the correct architecture for B2B multi-tenant portals. Contact scope works for B2C (individual consumer) portals.


Scenario: Sensitive columns (salary, SSN) must never be exposed via the Web API.

  1. In Site Setting Webapi/<tablename>/fields, explicitly list only safe columns — never use * on sensitive tables.
  2. Apply Dataverse Field Security Profiles to sensitive columns as a second layer.
  3. Only add permitted fields to the Dataverse form used by the portal.

Warning: Using Webapi/table/fields = * on a table with sensitive data is a critical misconfiguration. Always use an explicit allowlist.


13. Liquid Templating — Advanced Patterns

What is the difference between {% assign %} and {% capture %}?

  • assign stores a simple value or object reference.
  • capture renders a block of Liquid/HTML into a string variable.
{% assign username = user.fullname %}

{% capture welcome_msg %}
  <p>Welcome back, {{ user.fullname }}!</p>
{% endcapture %}
{{ welcome_msg }}

How do you query Dataverse records using fetchxml in Liquid?

{% fetchxml my_cases %}
<fetch top="10">
  <entity name="incident">
    <attribute name="title"/>
    <attribute name="statecode"/>
    <filter>
      <condition attribute="customerid"
        operator="eq" value="{{ user.id }}"/>
    </filter>
  </entity>
</fetch>
{% endfetchxml %}

{% for case in my_cases.results.entities %}
  <p>{{ case.title }} — {{ case.statecode.label }}</p>
{% endfor %}

How do you access the currently logged-in user's details?

{% if user %}
  Hello, {{ user.fullname }}
  Contact ID: {{ user.id }}
  Email: {{ user.emailaddress1 }}
  Account: {{ user.parentcustomerid.name }}
{% else %}
  <a href="/signin">Please sign in</a>
{% endif %}

Tip: user.roles returns the collection of web roles. Use user.roles | where: 'name', 'Admin' | first to check for a specific role.


How do you conditionally show content based on web role?

{% assign is_partner = user.roles
    | where: 'name', 'Partner'
    | first %}

{% if is_partner %}
  <a href="/partner-dashboard">Partner portal</a>
{% endif %}

Warning: This controls UI rendering only — it does not replace Table Permissions.


How do you read URL query string parameters in Liquid?

{% comment %} URL: /orders?status=active&page=2 {% endcomment %}

{{ request.params['status'] }}   → active
{{ request.params['page'] }}     → 2

Security Note: Never use raw query string values directly in fetchxml conditions without validation.


14. Liquid Templating — Web Templates

What is a Web Template and how does it differ from a Page Template?

  • Web Template: contains the actual Liquid + HTML markup (the code layer).
  • Page Template: configuration record referencing a Web Template, defining how it is used.

Chain: Web Page → Page Template → Web Template


How do you create a reusable component across multiple pages?

{%- comment -%} Web Template: "case-card" {%- endcomment -%}
<div class="card">
  <h3>{{ case.title }}</h3>
  <p>{{ case.statecode.label }}</p>
</div>

{%- comment -%} Parent Template {%- endcomment -%}
{% for case in my_cases.results.entities %}
  {% include 'case-card' %}
{% endfor %}

How do you pass parameters into an included Web Template?

{% include 'alert-banner'
    message: 'Your submission was received.'
    type: 'success' %}

{%- comment -%} Web Template: "alert-banner" {%- endcomment -%}
<div class="alert alert-{{ type }}">
  {{ message }}
</div>

What is the block and extends pattern in Web Templates?

{%- comment -%} Base layout Web Template {%- endcomment -%}
<html>
  <body>
    <header>{% block header %}Default header{% endblock %}</header>
    <main>{% block content %}{% endblock %}</main>
    <footer>{% block footer %}Default footer{% endblock %}</footer>
  </body>
</html>

{%- comment -%} Child Web Template {%- endcomment -%}
{% extends 'base-layout' %}
{% block content %}
  <h1>My Page Content</h1>
{% endblock %}

15. Liquid Templating — Performance

Why is putting multiple fetchxml blocks on one page a performance problem?

Each {% fetchxml %} is a synchronous round-trip to Dataverse during server-side rendering. Five fetchxml blocks = five blocking Dataverse calls before the user sees anything.

Fixes:

  • Merge related queries using link-entity fetchxml.
  • Move non-critical data fetches client-side using the Web API.
  • Cache expensive results in a Content Snippet updated by a flow.
  • Always use top in fetchxml — never fetch unbounded result sets.

Target: 1–2 server-side queries max per page.


What is the N+1 fetchxml anti-pattern?

{%- comment -%} NEVER do this {%- endcomment -%}
{% for order in orders.results.entities %}
  {% fetchxml lines %}
    <fetch><entity name="salesorderdetail">
      <filter><condition attribute="salesorderid"
        operator="eq" value="{{ order.id }}"/>
      </filter>
    </entity></fetch>
  {% endfetchxml %}
{% endfor %}

Fix: Use <link-entity> in a single fetchxml query.


How do you use link-entity in fetchxml to avoid multiple queries?

{% fetchxml orders_with_account %}
<fetch top="20">
  <entity name="salesorder">
    <attribute name="name"/>
    <attribute name="totalamount"/>
    <link-entity name="account"
      from="accountid" to="customerid"
      alias="acct">
      <attribute name="name"/>
    </link-entity>
  </entity>
</fetch>
{% endfetchxml %}

{% for o in orders_with_account.results.entities %}
  {{ o.name }} — {{ o['acct.name'] }}
{% endfor %}

16. Liquid Templating — Gotchas & Traps

Why does {{ value | default: 'N/A' }} sometimes still output blank?

The default filter only triggers when the value is nil, false, or "". If the Dataverse field returns an empty object (e.g., a lookup), default does not fire.

{% comment %} Safe pattern for lookups {% endcomment %}
{% if entity.parentcustomerid %}
  {{ entity.parentcustomerid.name }}
{% else %}
  N/A
{% endif %}

Why does modifying a variable inside a {% for %} loop not persist after the loop ends?

Liquid has block scoping — variables assigned inside a for loop do not leak out to the parent scope.

{% assign total = 0 %}
{% for item in items %}
  {% assign total = total | plus: item.amount %}
{% endfor %}
{{ total }}  ← always outputs 0!

Fix: Use fetchxml aggregate functions or calculate client-side with JavaScript.


How do you output a literal {{ or {% without Liquid interpreting it?

{% raw %}
  Vue template: {{ message }}
  Angular binding: {{ user.name }}
  Handlebars: {{#each items}}
{% endraw %}

Essential when embedding JavaScript frameworks (Vue, Angular, Handlebars) inside a Web Template.


Why does {% include 'my-template' %} silently render nothing?

Three common causes:

  1. The Web Template name is incorrect — names are case-sensitive.
  2. The included template has a Liquid error — Liquid silently swallows errors in includes.
  3. The included template references variables not in scope at the point of inclusion.

Liquid fetchxml returns empty but XrmToolBox shows records. Why?

The portal executes fetchxml in the context of the portal application user — not as an admin.

  1. Table Permissions: the web role lacks Read permission on the table.
  2. Record ownership: with Contact scope, no records are linked to the current Contact.
  3. Business Unit security: the portal application user's Dataverse role may not have org-level read.

Warning: Always test fetchxml in the browser as a portal user, not as an admin in XrmToolBox.


17. Liquid Cheat Sheet

Global Objects

Object Description Key Properties
user Logged-in Contact record. null for anonymous. .fullname, .id, .emailaddress1, .parentcustomerid.name, .roles
request Current HTTP request .url, .path, .params['key']
page Current web page record .title, .url, .parent.title
website Portal website record .name, .id
sitemarkers Named page pointers sitemarkers['Name'].url
settings Site Settings values settings['Key/Name']
snippets Content Snippets snippets['Snippet/Name']
weblinks Web link sets (nav menus) weblinks['Set Name'].weblinks

Essential Tags

Tag Purpose
{% fetchxml result %}...{% endfetchxml %} Query Dataverse. Results in result.results.entities
{% include 'template-name' key: val %} Include another Web Template with optional params
{% extends 'layout' %} Inherit from a base layout template
{% block name %}...{% endblock %} Define/override a named block region
{% assign var = value %} Store a value in a variable
{% capture var %}...{% endcapture %} Render Liquid block into a string variable
{% raw %}...{% endraw %} Output {{ }} literally — for Vue/Angular/Handlebars
{% unless condition %} Render when condition is false
{% for item in collection %} Loop over a collection
{% if / elsif / else / endif %} Conditional rendering

Essential Filters

Filter Example Output
date {{ date | date: "%d %b %Y" }} 18 Apr 2025
where user.roles | where: 'name', 'Admin' Filtered array
first / last collection | first First/last item
map roles | map: 'name' Array of names
join arr | join: ', ' A, B, C
size entities | size Count
truncate text | truncate: 80 Shortened string
default value | default: 'N/A' Fallback (strings/nil only)
downcase / upcase email | downcase Normalised case
replace str | replace: 'a','b' Substituted string
plus / minus / times / divided_by 10 | plus: 5 Arithmetic result

Critical Patterns to Memorise

{% comment %} Role check {% endcomment %}
{% assign is_admin = user.roles | where: 'name', 'Admin' | first %}
{% if is_admin %}...{% endif %}

{% comment %} Null-safe lookup {% endcomment %}
{% if entity.parentcustomerid %}
  {{ entity.parentcustomerid.name }}
{% endif %}

{% comment %} Whitelist URL params {% endcomment %}
{% assign allowed = 'active,inactive' | split: ',' %}
{% assign status = request.params['status'] %}
{% if allowed contains status %}
  use {{ status }} safely
{% endif %}

{% comment %} link-entity JOIN (avoid N+1) {% endcomment %}
<link-entity name="account" from="accountid"
  to="customerid" alias="acct">
  <attribute name="name"/>
</link-entity>
{{ entity['acct.name'] }}

Top 3 Traps

  1. Loop scope — {% assign %} inside {% for %} does NOT persist after the loop.
  2. default filter — fails silently on empty lookup objects. Use {% if %} guards instead.
  3. fetchxml inside a loop — causes N+1 Dataverse queries. Always use link-entity for related data.


Featured Post

Provisioning an SMTP AUTH Service Account for On-Prem Apps (Hybrid Exchange + PowerShell 7)

Provisioning an SMTP AUTH Service Account for On-Prem Apps (Hybrid Exchange + PowerShell 7) Complete Script (PowerShell 7 compatible) <# ...

Popular posts