How Page Models Work in .NET

Page Models define the structure and layout of pages in Agility CMS. They determine where editors can place components and how the page is visually organized.

What is a Page Model?

A Page Model is a template that defines:

  1. Name - A descriptive identifier (e.g., "Main Template", "Two Column Layout", "Blog Page")
  2. Zones - Areas where editors can add, edit, and remove components

Think of Page Models as the skeleton of a page. They define the layout structure, while components fill in the actual content.

Example Page Models

Single Column Layout

┌─────────────────────────────┐
│        Header Zone          │
├─────────────────────────────┤
│                             │
│     Main Content Zone       │
│                             │
├─────────────────────────────┤
│        Footer Zone          │
└─────────────────────────────┘

Two Column Layout

┌─────────────────────────────┐
│        Header Zone          │
├──────────────┬──────────────┤
│              │              │
│   Left Zone  │  Right Zone  │
│              │              │
├──────────────┴──────────────┤
│        Footer Zone          │
└─────────────────────────────┘

Blog Post Layout

┌─────────────────────────────┐
│        Hero Zone            │
├─────────────────────────────┤
│     Article Content Zone    │
├─────────────────────────────┤
│     Related Posts Zone      │
└─────────────────────────────┘

Creating Page Models in Agility CMS

  1. Go to Settings > Page Models in Agility CMS
  2. Click New to create a Page Model
  3. Enter a Name (e.g., "Main Template")
  4. Add Zones - these become the areas where editors place components

Each zone you create will be available in the code for rendering.

Implementing Page Models in .NET

Blazor Implementation

In Blazor, Page Models are implemented as layouts or conditionally rendered sections:

@* Components/Pages/AgilityPage.razor *@
@page "/{*slug}"

<MainLayout>
    @foreach (var zone in PageData.Zones)
    {
        <section class="zone zone-@zone.Key.ToLower().Replace(" ", "-")">
            @foreach (var module in zone.Value.Modules)
            {
                <AgilityComponent
                    ComponentName="@module.ModuleName"
                    Fields="@module.Fields"
                    CustomData="@module.CustomData" />
            }
        </section>
    }
</MainLayout>

For different layouts based on Page Model name:

@switch (PageData.TemplateName)
{
    case "Main Template":
        <MainLayout>
            @RenderZones()
        </MainLayout>
        break;

    case "Two Column Layout":
        <TwoColumnLayout LeftZone="@GetZone("LeftZone")"
                        RightZone="@GetZone("RightZone")" />
        break;

    case "Blog Page":
        <BlogLayout HeroZone="@GetZone("HeroZone")"
                   ContentZone="@GetZone("ContentZone")" />
        break;
}

MVC Implementation

In MVC, Page Models map to Razor view files in the Views/PageTemplates/ folder:

Views/
└── PageTemplates/
    ├── MainTemplate.cshtml
    ├── TwoColumnLayout.cshtml
    └── BlogPage.cshtml

The Page Model name is converted to a file path by removing spaces:

Page Model NameView File
Main TemplateMainTemplate.cshtml
Two Column LayoutTwoColumnLayout.cshtml
Blog PageBlogPage.cshtml

Main Template Example

@* Views/PageTemplates/MainTemplate.cshtml *@
@model AgilityPageModel

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    @await Component.InvokeAsync("SEO", Model)
    <link href="~/css/output.css" rel="stylesheet" />
</head>
<body>
    @await Component.InvokeAsync("SiteHeader")

    <main>
        @* Render the MainContentZone *@
        @await Html.RenderZoneAsync("MainContentZone")
    </main>

    @await Component.InvokeAsync("SiteFooter")
</body>
</html>

Two Column Layout Example

@* Views/PageTemplates/TwoColumnLayout.cshtml *@
@model AgilityPageModel

<!DOCTYPE html>
<html lang="en">
<head>
    @await Component.InvokeAsync("SEO", Model)
    <link href="~/css/output.css" rel="stylesheet" />
</head>
<body>
    @await Component.InvokeAsync("SiteHeader")

    <main class="container mx-auto">
        <div class="flex flex-wrap -mx-4">
            <div class="w-full lg:w-2/3 px-4">
                @await Html.RenderZoneAsync("LeftZone")
            </div>
            <aside class="w-full lg:w-1/3 px-4">
                @await Html.RenderZoneAsync("RightZone")
            </aside>
        </div>
    </main>

    @await Component.InvokeAsync("SiteFooter")
</body>
</html>

The RenderZoneAsync Helper

Both starters include a helper for rendering zones. It:

  1. Gets the zone from the page data
  2. Iterates through each module in the zone
  3. Invokes the appropriate component for each module
// Simplified implementation
public static async Task RenderZoneAsync(this IHtmlHelper html, string zoneName)
{
    var page = html.ViewData["PageData"] as PageResponse;
    var zone = page?.Zones?.GetValueOrDefault(zoneName);

    if (zone?.Modules == null) return;

    foreach (var module in zone.Modules)
    {
        await html.RenderComponentAsync(module);
    }
}

Dynamic Page Model Selection

The page rendering system automatically selects the correct Page Model based on the TemplateName property from Agility:

// Get the template path
var templatePath = PageHelpers.GetPageTemplatePath(page.TemplateName);
// Returns: "/Views/PageTemplates/MainTemplate.cshtml"
// PageHelpers.cs
public static string GetPageTemplatePath(string templateName)
{
    // Remove spaces from template name
    var fileName = templateName.Replace(" ", "");
    return $"/Views/PageTemplates/{fileName}.cshtml";
}

Zone Data Structure

When you fetch a page, zones are returned as a dictionary:

{
  "zones": {
    "MainContentZone": {
      "modules": [
        {
          "moduleName": "RichTextArea",
          "fields": {
            "textBlob": "<p>Hello world</p>"
          }
        },
        {
          "moduleName": "TextBlockWithImage",
          "fields": {
            "title": "Our Services",
            "content": "<p>We offer...</p>",
            "image": { "url": "...", "label": "..." }
          }
        }
      ]
    },
    "SidebarZone": {
      "modules": [
        {
          "moduleName": "Newsletter",
          "fields": {
            "heading": "Subscribe",
            "buttonText": "Sign Up"
          }
        }
      ]
    }
  }
}

Best Practices

1. Use Descriptive Zone Names

Choose zone names that clearly indicate their purpose:

GoodAvoid
MainContentZoneZone1
HeroSectionTop
SidebarWidgetsRight
FooterLinksBottom

2. Keep Page Models Flexible

Design Page Models that work for multiple page types. A "Main Template" with a single content zone is more flexible than many specialized templates.

3. Document Zone Purposes

Add comments or documentation so editors know what types of components work best in each zone:

@* MainContentZone: Full-width content modules *@
@await Html.RenderZoneAsync("MainContentZone")

@* SidebarZone: Narrow widgets, CTAs, navigation *@
@await Html.RenderZoneAsync("SidebarZone")

4. Handle Missing Zones Gracefully

Not all pages use all zones. Check for null zones:

@if (PageData.Zones.ContainsKey("SidebarZone"))
{
    <aside>
        @await Html.RenderZoneAsync("SidebarZone")
    </aside>
}

5. Consider Mobile Layouts

Design zones that work well on all screen sizes:

<div class="flex flex-col lg:flex-row">
    <main class="w-full lg:w-2/3">
        @await Html.RenderZoneAsync("MainContentZone")
    </main>
    <aside class="w-full lg:w-1/3 mt-8 lg:mt-0">
        @await Html.RenderZoneAsync("SidebarZone")
    </aside>
</div>

Shared Components

Some components appear on every page regardless of Page Model (like headers and footers). These are typically:

  1. Hard-coded in the Page Model template
  2. Fetched from a shared content item in Agility
@* Always include header and footer *@
@await Component.InvokeAsync("SiteHeader")

<main>
    @await Html.RenderZoneAsync("MainContentZone")
</main>

@await Component.InvokeAsync("SiteFooter")

Next Steps