Development 29 September 2026 7 min read

Calling Server Logic from Liquid: the full circle in Power Pages

The serverlogic Liquid tag renders reusable server-side logic at page render, no client call, no CSRF. The N:M read from part one, now closed. Verified live.

This is the third and final part of a small arc about reading a many-to-many relationship in Power Pages, and it closes the loop in a satisfying way.

Part one read N:M with Liquid FetchXML: server-rendered, but preloaded at page render. Part two moved the same read into Server Logic called on demand from the client, no query in the browser, but a client round-trip with a CSRF token. Each side had a cost.

The serverlogic Liquid tag removes both costs at once. The same server-side operation from part two, invoked from Liquid while the page renders. No client call, no CSRF token, and the query still never touches the browser. Microsoft shipped this in 2026, and it is the piece that makes the whole thing a circle: reusable, testable server logic, rendered straight into the page like FetchXML was.

The tag

{% serverlogic name: 'installation-products',
             operation: 'getInstallationProducts',
             output: result %}

{% if result.success %}
  <ul>
  {% for inst in result.data.installations %}
    <li>{{ inst.name | escape }}</li>
  {% endfor %}
  </ul>
{% endif %}

Four parameters: name (the server logic record), operation (the JavaScript function to run, any function you define, not just get), input (optional string, JSON when you need structure), and output (the Liquid variable that receives the result). The result object carries success, status_code (500 on an execution error), data (the parsed JSON your operation returned), and raw_result (the unparsed response, for when the return value is not valid JSON).

The key line from Microsoft's docs: this server-side invocation "doesn't require a client-side HTTP request or Cross-Site Request Forgery (CSRF) token." Assign the server logic record to a web role the current user can access, and the tag does the rest at render time.

The full circle, live

I built exactly this in the demo portal: a web template with the tag above, calling an operation that reads the N:M relationship from part one and returns a compact list. Signed in as a portal user, the page renders the linked products server-side:

A Power Pages web template using the serverlogic Liquid tag renders the N:M installation-to-product list server-side, result.success true, status_code 200

Same data as part one (Pump A, Valve B under an installation), same server-side Dataverse connector as part two, now rendered directly into the page by Liquid. That is the circle closed.

One function, two contexts

Here is the detail that saves you an afternoon. The same server logic function can be called two ways, from the client over HTTP and from Liquid, but it reads its input from two different places:

  • Called over HTTP: Server.Context.QueryParameters["id"], plus Body, Headers, HttpMethod.
  • Called from Liquid: Server.Context.Input, the string you passed in the tag's input parameter.

If you write one operation for both entry points, branch on the input source. QueryParameters is empty in a Liquid invocation, and Input is empty over HTTP.

Two things the docs do not spell out

I hit both while building the live example, so you do not have to.

The connector returns an envelope. When you consume the result inside the operation (rather than returning it straight through), Server.Connector.Dataverse.RetrieveMultipleRecords gave me a { "StatusCode": 200, "Body": "..." } string, where Body is the OData JSON as a string. You parse twice: the envelope, then Body, then reach value. The docs' examples return the connector result directly and never show this, so the first time you try to loop over result.value you get nothing.

function getInstallationProducts() {
  var options = "$select=pwrprtl_installation_name" +
    "&$expand=pwrprtl_installation_product($select=pwrprtl_product_name)";
  var res = Server.Connector.Dataverse.RetrieveMultipleRecords("pwrprtl_installations", options, true);
  var envelope = (typeof res === "string") ? JSON.parse(res) : res;
  var odata = (typeof envelope.Body === "string") ? JSON.parse(envelope.Body) : envelope.Body;
  var rows = (odata && odata.value) ? odata.value : [];

  var installations = [];
  for (var i = 0; i < rows.length; i++) {
    var prods = rows[i].pwrprtl_installation_product || [];
    var names = [];
    for (var j = 0; j < prods.length; j++) { names.push(prods[j].pwrprtl_product_name); }
    installations.push({ name: rows[i].pwrprtl_installation_name, products: names });
  }
  return JSON.stringify({ installations: installations });
}

Compiled server logic is cached. After you edit the code, the endpoint and the Liquid tag keep serving the previous version until the site cache is cleared (/_services/about, Clear cache, with an administrator web role). If a change does not seem to take, it is the cache, not your code.

Security: no CSRF is not no responsibility

Skipping the CSRF token is safe here because the call never leaves the server; there is no cross-site request to forge. But the operation still runs in the current user's context and respects web roles and table permissions, and the authorization decisions are still yours. Microsoft is explicit: treat input and returned values as untrusted. Validate Server.Context.Input before it touches a query, and apply the escape filter before rendering any returned string into HTML, exactly as in the tag above.

And the one real cost: the tag runs while the page renders, so a slow Dataverse query or external call increases page response time. That is the trade you make for server-side rendering. When responsiveness matters more than first paint, keep the client call from part two.

Three server-side ways, one decision

You want Use
N:M (or any read) rendered at page load, simple query Liquid FetchXML (link-entity / intersect)
Reusable/testable logic rendered at page load {% serverlogic %} tag (Server.Context.Input)
The same logic on demand, after load Server Logic over HTTP (safeAjax, CSRF, QueryParameters)
Write N:M (associate / disassociate) Portal Web API $ref

FetchXML is the quickest path for a plain query. The moment the logic is worth reusing or unit-testing, {% serverlogic %} gives you that at render time, and the same operation answers a client call after load. Three tools, one mental model: decide by when the user needs the data and how much logic sits behind it. That is the arc, closed.

Related articles

Sources

Tino Rabe

Tino Rabe

Power Pages Spezialist · Former Microsoft MVP

Power Pages specialist, former Microsoft MVP. I help companies build secure customer portals: architecture workshop, weekly coaching, security audits.

When was your portal last independently reviewed?

Fixed-fee security audit, or just talk it through first.

Book a call