# Microsoft Power Pages

> Source: https://agilitycms.com/docs/developers/microsoft-power-pages

> [!NOTE]
> We last checked this guide on November 2, 2026, by building a Power Pages site that reads its content from Agility. Microsoft updates Power Pages often, so if something here doesn't match what you see, Microsoft's documentation has the latest.

Many organizations run their intranet on Power Pages. IT likes it: it's part of the Microsoft stack, and people sign in with their work accounts. But Power Pages wasn't built for communicators, so every news post or policy update turns into a request to IT. Agility fills that gap. Editors write, review and publish in Agility, and the Power Pages site shows the change about 30 seconds later.

## At a glance

- **How it works:** the Power Pages site reads content from the Agility Content Fetch API each time a page loads. Nothing is exported, synced or copied into Dataverse, and Agility doesn't generate Power Pages pages.
- **What you build:** a few site settings, one server logic record (about 60 lines of JavaScript) and a Liquid web template.
- **What editors get:** publish to live in about 30 seconds, preview before publishing, approvals, and click-to-edit in Web Studio when the site can be shown without sign-in.
- **Who it's for:** a developer or Power Platform maker who can edit the site in design studio.
- **Good fit:** intranets and portals where everyone sees the same content. If different people need different content, read [Reference Architecture: Member Portal and Intranet](/docs/developers/reference-architecture-member-portal) first.

## How it fits together

| Piece | Lives in | Does |
| --- | --- | --- |
| Content models, lists and items | Agility | What editors create, approve and publish |
| Server logic (JavaScript) | Power Pages | Calls the Agility Fetch API with keys held in site settings, and returns JSON |
| Web template (Liquid) | Power Pages | Calls the server logic with the `serverlogic` tag and renders the HTML |
| Site settings | Power Pages | Hold the instance GUID, API keys and the expected preview key |
| Sign-in, web roles, page layout | Power Pages | Who can see the site, and the page structure |

The API keys stay on the server. The browser only receives the rendered HTML.

## Before you start

- A Power Pages site, and permission to edit it in design studio.
- Server logic available on the site. Microsoft's site setting `ServerLogic/Enabled` controls it, and `ServerLogic/AllowedDomains` can restrict which hosts it may call. If you restrict domains, allow the Agility Fetch API host for your data region (see [Content Security Policy and Network Allowlist](/docs/developers/content-security-policy-and-network-allowlist)).
- Outbound calls not blocked. A tenant administrator can block server logic from calling external services, site by site.
- Your Agility instance GUID, a **Fetch** API key and a **Preview** API key, from **Settings > API Keys** in Agility.

## Step 1: Model the content in Agility

Start with the content, not the page. Each kind of thing the site shows becomes a shared content list in Agility. For an intranet that might be:

| List (reference name) | Fields |
| --- | --- |
| `announcements` | Title, Summary, Body (HTML), Image, Category, Post Date, Pinned (True/False) |
| `quicklinks` | Title, Link (URL), Description |
| `documentlinks` | Title, Description, Document (URL to the file in SharePoint), Category, Department |
| `handbooksections` | Title, Summary, Body (HTML), Sort Order |

The Fetch API reads a list by its reference name, in lowercase: `/fetch/en-us/list/announcements`. Turn on approvals for the lists that need review. See [Content Models](/docs/developers/content-models).

## Step 2: Store the keys in site settings

Site settings are where Power Pages keeps configuration. Putting the keys there keeps them out of your code and away from the browser. In the Power Pages Management app, add:

| Site setting | Value |
| --- | --- |
| `Agility/Guid` | Your instance GUID |
| `Agility/FetchKey` | The Fetch API key (published content) |
| `Agility/PreviewKey` | The Preview API key (staging content) |
| `Agility/PreviewToken` | The expected `agilitypreviewkey` value (see [Step 5](#step-5-turn-on-preview)) |

The setting names are your choice. Keep them consistent with the server logic.

## Step 3: Add the server logic

Server logic is JavaScript that runs on Power Pages' servers. It's the only piece that talks to Agility.

In design studio, go to **Set up > Server logic**, create a server logic record named `agility`, and assign it to the web roles that can see the pages it serves (for example **Authenticated Users**, plus **Anonymous Users** if the site is public). Then edit its code.

This version exposes one operation, `list`, that reads a content list. It only reads the lists you name, and it reads staging content only when the preview key checks out.

```javascript
const ALLOWED_LISTS = ["announcements", "quicklinks", "documentlinks", "handbooksections"];
const API_HOST = "https://api.aglty.io"; // use the Fetch API host for your data region

async function fetchList(ref, preview, take) {
  if (ALLOWED_LISTS.indexOf(ref) < 0) {
    return { ok: false, error: "Unknown list: " + ref, items: [] };
  }
  const guid = Server.SiteSetting.Get("Agility/Guid");
  const key = Server.SiteSetting.Get(preview ? "Agility/PreviewKey" : "Agility/FetchKey");
  const url = API_HOST + "/" + guid + "/" + (preview ? "preview" : "fetch") +
    "/en-us/list/" + ref + "?take=" + take + "&contentLinkDepth=0";

  const response = JSON.parse(await Server.Connector.HttpClient.GetAsync(url, { APIKey: key }));
  if (!response.IsSuccessStatusCode) {
    return { ok: false, error: "Agility returned " + response.StatusCode, items: [] };
  }

  const body = JSON.parse(response.Body);
  const items = (body.items || []).map((item) => {
    return { contentID: item.contentID, fields: item.fields || {} };
  });
  return { ok: true, preview: preview, items: items };
}

// The expected agilitypreviewkey value is stored in a site setting (see Step 5).
function isPreviewKey(key) {
  const expected = Server.SiteSetting.Get("Agility/PreviewToken");
  // A '+' in the key can arrive as a space after URL decoding.
  return !!key && !!expected && String(key).replace(/ /g, "+") === expected;
}

// Called from Liquid: {% serverlogic name: 'agility', operation: 'list', input: ..., output: ... %}
async function list() {
  try {
    const input = JSON.parse(Server.Context.Input || "{}");
    const take = Math.min(parseInt(input.take || "50", 10) || 50, 100);
    const preview = isPreviewKey(input.previewKey);
    return JSON.stringify(await fetchList(String(input.ref || ""), preview, take));
  } catch (e) {
    return JSON.stringify({ ok: false, error: String(e && e.message ? e.message : e), items: [] });
  }
}
```

> [!WARNING]
> **Use arrow functions for callbacks.** In our testing, an anonymous `function (item) { … }` expression passed to `.map` made the whole script fail with only "Exception occurred while processing this request", even inside `try`/`catch`. Named top-level `function` declarations work.

A content list returns 10 items unless you pass `take`. The maximum is 250. See [Content Fetch API](/docs/developers/content-fetch-api).

## Step 4: Render the content in a web template

This is where content becomes HTML. Create a web template (and a page template that uses it), then call the server logic once per list. The `serverlogic` tag returns an object with `success` and `data`, where `data` is the parsed JSON your function returned.

```liquid
{% assign pkey = request.params['agilitypreviewkey'] | default: '' | replace: '"', '' %}
{% capture in_news %}{"ref":"announcements","previewKey":"{{ pkey }}","take":20}{% endcapture %}
{% serverlogic name: 'agility', operation: 'list', input: in_news, output: news %}

{% if news.data.preview %}<div class="preview-banner">Preview: showing unpublished content</div>{% endif %}

<section id="news">
  <h2>News</h2>
  {% if news.success and news.data.ok %}
    {% for a in news.data.items %}
    <article>
      <h3>{{ a.fields.title | escape }}</h3>
      <p>{{ a.fields.summary | escape }}</p>
      <div>{{ a.fields.body }}</div>
    </article>
    {% endfor %}
  {% else %}
    <p>News couldn't be loaded. {{ news.data.error | default: news.error | escape }}</p>
  {% endif %}
</section>
```

- **Escape text fields** with `| escape`. Output HTML fields (such as Body) unescaped only when they come from an Agility HTML field your editors control.
- **Strip quotes from the preview key** before building the JSON input, as above, so a crafted value can't add fields to it.
- **Each `serverlogic` call adds a request to page rendering.** Call only the lists a page shows, and keep `take` small.
- **Sort in Liquid or in the server logic.** For example, put pinned items first and then order by date. The Fetch API also takes `sort` and `direction` parameters.

## Step 5: Turn on preview

Editors want to see a change before anyone else does. When they click **Preview**, Agility opens your site with a key in the URL. Your job is to check that key, so drafts show for editors and nobody else.

The key arrives as `?agilitypreviewkey=`. The `@agility/nextjs` and .NET SDKs check it for you; on Power Pages you do it yourself.

The expected value is the Base64-encoded SHA-512 hash of the UTF-16LE string `-1_{securityKey}_Preview`, where `{securityKey}` is your instance's Security Key (**Settings > API Keys**). Server logic has no hashing functions, so compute the value once and store it in the `Agility/PreviewToken` site setting. With Node.js:

```bash
node -e 'const k=process.argv[1];console.log(require("crypto").createHash("sha512").update(Buffer.from(`-1_${k}_Preview`,"utf16le")).digest("base64"))' "YOUR_SECURITY_KEY"
```

Recompute it if the Security Key is reset.

> [!IMPORTANT]
> Don't switch to staging content just because the parameter is present, or because of a parameter such as `?preview=true`. Anyone could add it to a URL and read unpublished content.

Then register the site so editors can open it from Agility: **Settings > Sitemaps > Setup Deployment > Custom Deployment**, with your Power Pages URL. See [Setting Up Preview](/docs/developers/setting-up-preview).

## Step 6: Edit in context with Web Studio

Web Studio shows the real site inside Agility. Editors hover over any piece of content, click it, and watch their change appear as they type. The general setup is in [Set Up Web Studio with Any Framework](/docs/developers/web-studio-setup-any-framework). On Power Pages:

1. **Allow framing.** Add the site settings `HTTP/Content-Security-Policy` = `frame-ancestors 'self' https://app.agilitycms.com;` and `HTTP/SameSite/Default` = `None`.
2. **Give Web Studio a page to open.** Web Studio opens pages from an Agility sitemap. Create a page in Agility whose path matches the Power Pages page (for example `home` for `/home`), and put its page ID on the element that wraps your content: `data-agility-page="2"`.
3. **Identify the instance.** The Power Pages layout owns `<body>`, so set the GUID from a script: `document.body.setAttribute("data-agility-guid", "YOUR_INSTANCE_GUID")`.
4. **Load the SDK:** `<script src="https://unpkg.com/@agility/web-studio-sdk@latest/dist/index.js"></script>`.
5. **Tag list items with `data-agility-nested-listitem`**, not `data-agility-component`. Items read from a list are not components on the Agility page. Tagged as components, Web Studio opened a blank new item; tagged as nested list items, it opens the real item and updates it live as the editor types.

```liquid
<article data-agility-nested-listitem="{{ a.contentID }}">
  <h3 data-agility-field="title">{{ a.fields.title | escape }}</h3>
  <div data-agility-field="body" data-agility-html="true">{{ a.fields.body }}</div>
</article>
```

### Private sites and Web Studio

A Power Pages site that requires Microsoft sign-in **does not load inside Web Studio**. Inside the cross-site frame, the browser doesn't send the site's sign-in cookie, so Power Pages redirects to Microsoft sign-in, and Microsoft sign-in refuses to load in a frame. Signing in on another tab doesn't help.

You have two options:

| Option | What editors get | Trade-off |
| --- | --- | --- |
| Keep the site private | Preview links that open in a new browser tab, where the editor is signed in | No in-context editing in Web Studio |
| Make a site public for Web Studio (for example a separate non-production site) | Full Web Studio: outlines, click-to-edit, live updates | Anyone with the URL can read the **published** content. Staging content still needs a valid preview key |

On a trial or other non-production environment, making a site public also needs a tenant setting: Power Platform admin center **> Manage > Power Pages > Settings > Site visibility for non-production sites**. Until it allows the site, `pac power-pages update-site-visibility` fails with a bare HTTP 400.

## Deploying changes

| Change | How |
| --- | --- |
| Web templates, page templates, site settings that already exist | `pac pages upload --path <site folder> --modelVersion 2` |
| Server logic code | Edit it in design studio, or upload the record's `.sl` file content through the Dataverse Web API. `pac pages` doesn't handle server logic |
| New records on enhanced data model sites | Create them in design studio or through the Dataverse Web API. In our testing (pac 2.14.1), `pac pages upload` left empty records it couldn't update or delete |

> [!TIP]
> Server logic code uploaded through the Dataverse Web API wasn't picked up until the site restarted: `pac power-pages restart-website --id <portal id>`, then about a minute.

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| "Exception occurred while processing this request" from server logic | An anonymous `function (…)` expression, or reading a missing query parameter. `Server.Context.QueryParameters["x"]` throws when `x` isn't there, so wrap reads in `try`/`catch` |
| A code change has no effect | The site hasn't restarted since the upload |
| `news.success` is false | The server logic record isn't assigned to the current user's web role, outbound calls are blocked, or the Agility host isn't in `ServerLogic/AllowedDomains` |
| `Agility returned 401` | Wrong key in site settings, or the Preview key used against `/fetch` (or the Fetch key against `/preview`) |
| A published change takes a while to show | Power Pages doesn't add its own caching. Allow about 30 seconds for Agility's CDN and refresh |
| Preview shows published content | `Agility/PreviewToken` doesn't match the current Security Key, or the key was mangled (`+` arriving as a space) |
| Web Studio shows Microsoft sign-in, or a refused-to-connect message | The site is private, or the CSP site setting is missing. See [Private sites and Web Studio](#private-sites-and-web-studio) and [Troubleshooting Web Studio](/docs/developers/troubleshooting-web-studio) |

## Related

- [SharePoint](/docs/developers/sharepoint)
- [Editing a Power Pages Site with Agility](/docs/editors/editing-a-power-pages-site) (for editors)
- [Migrate from SharePoint or Power Pages to Agility](/docs/developers/migrate-from-sharepoint-and-power-pages)
- [Reference Architecture: Member Portal and Intranet](/docs/developers/reference-architecture-member-portal)
- [Set Up Web Studio with Any Framework](/docs/developers/web-studio-setup-any-framework)
- [Enterprise Single Sign-On with Agility](/docs/owners-admins/enterprise-sso-with-agility-cms)
- Microsoft: [Server logic overview](https://learn.microsoft.com/power-pages/configure/server-logic-overview), [Author server logic](https://learn.microsoft.com/power-pages/configure/author-server-logic), [Server objects](https://learn.microsoft.com/power-pages/configure/server-objects)

---

## Related pages

- [Building Integrations](https://agilitycms.com/docs/developers/building-integrations.md)
- [Zapier Translate](https://agilitycms.com/docs/developers/zapier-translate.md)
- [Vercel](https://agilitycms.com/docs/developers/vercel.md)
- [Ahrefs](https://agilitycms.com/docs/developers/ahrefs.md)
- [Netlify](https://agilitycms.com/docs/developers/netlify.md)
- [Salesforce Sales Cloud](https://agilitycms.com/docs/developers/salesforce-sales-cloud.md)
- [Auth](https://agilitycms.com/docs/developers/auth0.md)
- [User Generated Content (UGC)](https://agilitycms.com/docs/developers/user-generated-content-ugc.md)
- [SharePoint](https://agilitycms.com/docs/developers/sharepoint.md)

Index of every docs page: https://agilitycms.com/docs/llms.txt
