MVC Starter

The MVC Starter is a traditional ASP.NET Core Razor Pages template for building Agility CMS websites with server-side rendering.

Features

  • Razor Pages - Server-rendered pages with C# code-behind
  • ViewComponents - Reusable content modules matching Agility components
  • Dynamic Routing - Automatic page routing based on Agility sitemap
  • GraphQL Support - Query content using Agility's GraphQL API
  • URL Redirects - Middleware for Agility-managed redirects
  • Tailwind CSS - Utility-first styling with PostCSS
  • One-Click Azure Deployment - Pre-configured ARM template

Project Structure

Agility.NET.MVC.Starter/
├── Pages/
│   ├── AgilityPage.cshtml           # Dynamic page view
│   ├── AgilityPage.cshtml.cs        # Page model with content loading
│   ├── Error.cshtml
│   └── _ViewImports.cshtml
├── ViewComponents/
│   ├── PageModules/                 # Content modules
│   │   ├── FeaturedPost.cs
│   │   ├── Heading.cs
│   │   ├── PostDetails.cs
│   │   ├── PostsListing.cs
│   │   ├── RichTextArea.cs
│   │   └── TextBlockWithImage.cs
│   └── Shared/                      # Global components
│       ├── PreviewBar.cs
│       ├── SEO.cs
│       ├── SiteFooter.cs
│       └── SiteHeader.cs
├── Views/
│   ├── PageModules/                 # Component views
│   │   ├── FeaturedPost.cshtml
│   │   ├── Heading.cshtml
│   │   ├── PostDetails.cshtml
│   │   ├── PostsListing.cshtml
│   │   ├── RichTextArea.cshtml
│   │   └── TextBlockWithImage.cshtml
│   ├── PageTemplates/               # Page Model layouts
│   │   └── MainTemplate.cshtml
│   └── Shared/
│       ├── Components/              # Shared component views
│       └── _Layout.cshtml
├── Models/
│   ├── AgilityModels.cs             # Content models
│   ├── ModelExtensions.cs
│   └── ViewComponents/
├── Middleware/
│   ├── AgilityRouteTransformer.cs   # Dynamic routing
│   └── AgilityRedirectMiddleware.cs # URL redirects
├── Helpers/
│   ├── PageHelpers.cs
│   ├── PreviewHelper.cs
│   └── TransformerMiddlewareHelpers.cs
├── Util/
│   ├── Constants.cs
│   └── Utility.cs
├── wwwroot/
│   └── css/
│       ├── site.css                 # Tailwind input
│       └── output.css               # Generated CSS
├── appsettings.json                 # Default configuration
├── azuredeploy.json                 # Azure ARM template
├── Program.cs                       # Application host
├── Startup.cs                       # DI configuration
├── tailwind.config.js               # Tailwind configuration
└── postcss.config.js                # PostCSS configuration

How It Works

Request Flow

  1. Request arrives at the application
  2. AgilityRouteTransformer intercepts the request
  3. Sitemap lookup finds the matching page
  4. AgilityPage.cshtml.cs fetches page data from Agility API
  5. Page Model template is selected based on TemplateName
  6. ViewComponents render each module in page zones

Route Transformation

The AgilityRouteTransformer converts URLs to Agility pages:

// Middleware/AgilityRouteTransformer.cs
public override async ValueTask<RouteValueDictionary> TransformAsync(
    HttpContext httpContext, RouteValueDictionary values)
{
    var path = httpContext.Request.Path.Value ?? "/";

    // Get cached sitemap
    var sitemapPages = await GetSitemapPages(httpContext);

    // Find matching page
    var page = sitemapPages.FirstOrDefault(p =>
        p.Path.Equals(path, StringComparison.OrdinalIgnoreCase));

    if (page != null)
    {
        values["page"] = "/AgilityPage";
        values["agilityPage"] = page;
    }

    return values;
}

Page Rendering

The AgilityPage fetches and renders page content:

// Pages/AgilityPage.cshtml.cs
public class AgilityPageModel : PageModel
{
    private readonly FetchApiService _fetchApiService;

    public PageResponse? PageResponse { get; set; }
    public Dictionary<string, Zone>? ContentZones { get; set; }

    public async Task<IActionResult> OnGetAsync()
    {
        // Get the sitemap page from route data
        var sitemapPage = RouteData.Values["agilityPage"] as SitemapPage;

        if (sitemapPage == null)
            return NotFound();

        // Fetch full page data
        PageResponse = await _fetchApiService.GetTypedPage(
            pageId: sitemapPage.PageID,
            locale: sitemapPage.Locale,
            contentLinkDepth: 3
        );

        ContentZones = PageResponse.Zones;

        return Page();
    }
}

ViewComponents

ViewComponents are the MVC equivalent of Blazor components. Each has a C# class and Razor view.

Creating a ViewComponent

1. Create the C# Class

// ViewComponents/PageModules/CallToAction.cs
public class CallToAction : ViewComponent
{
    public IViewComponentResult Invoke(ModuleModel moduleModel)
    {
        var fields = moduleModel.Model.Fields.ToObject<CallToActionFields>();
        return View("/Views/PageModules/CallToAction.cshtml", fields);
    }
}

// Model class
public class CallToActionFields
{
    public string? Heading { get; set; }
    public string? Description { get; set; }
    public Link? Button { get; set; }
    public string? BackgroundColor { get; set; }
}

2. Create the View

@* Views/PageModules/CallToAction.cshtml *@
@model CallToActionFields

@{
    var bgClass = Model?.BackgroundColor switch
    {
        "primary" => "bg-primary-500 text-white",
        "secondary" => "bg-secondary-500 text-white",
        _ => "bg-gray-100"
    };
}

<section class="py-16 @bgClass">
    <div class="container mx-auto px-4 text-center">
        @if (!string.IsNullOrEmpty(Model?.Heading))
        {
            <h2 class="text-3xl font-bold mb-4">@Model.Heading</h2>
        }

        @if (!string.IsNullOrEmpty(Model?.Description))
        {
            <p class="text-xl mb-8 max-w-2xl mx-auto">@Model.Description</p>
        }

        @if (Model?.Button?.Href != null)
        {
            <a href="@Model.Button.Href"
               class="inline-block px-8 py-4 bg-white text-primary-600 font-semibold rounded-lg">
                @(Model.Button.Text ?? "Get Started")
            </a>
        }
    </div>
</section>

Components with Data Fetching

For components that need to fetch additional data:

// ViewComponents/PageModules/PostsListing.cs
public class PostsListing : ViewComponent
{
    private readonly FetchApiService _fetchApiService;

    public PostsListing(FetchApiService fetchApiService)
    {
        _fetchApiService = fetchApiService;
    }

    public async Task<IViewComponentResult> InvokeAsync(ModuleModel moduleModel)
    {
        var isPreview = PreviewHelpers.IsPreviewMode(HttpContext);

        // Fetch posts using GraphQL
        var posts = await _fetchApiService.GetContentByGraphQL<Post>(
            query: @"
                query {
                    posts(take: 10, sort: ""fields.date"", direction: ""desc"") {
                        contentID
                        fields {
                            title
                            slug
                            date
                            excerpt
                            image {
                                url
                                label
                            }
                        }
                    }
                }
            ",
            objName: "posts",
            locale: moduleModel.Locale,
            isPreview: isPreview
        );

        return View("/Views/PageModules/PostsListing.cshtml", posts);
    }
}

Page Model Templates

Page Models map to view files in Views/PageTemplates/:

Page Model NameView File
Main TemplateMainTemplate.cshtml
Two ColumnTwoColumn.cshtml
Blog PostBlogPost.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.PageResponse)
    <link href="~/css/output.css" rel="stylesheet" />
</head>
<body class="min-h-screen flex flex-col">
    @await Component.InvokeAsync("PreviewBar")
    @await Component.InvokeAsync("SiteHeader")

    <main class="flex-grow">
        @* Render the main content zone *@
        @await Html.RenderZoneAsync("MainContentZone", Model)
    </main>

    @await Component.InvokeAsync("SiteFooter")

    <script src="~/js/site.js"></script>
</body>
</html>

Zone Rendering Helper

The RenderZoneAsync helper renders all modules in a zone:

// Extension method
public static async Task RenderZoneAsync(
    this IHtmlHelper html,
    string zoneName,
    AgilityPageModel pageModel)
{
    var zone = pageModel.ContentZones?.GetValueOrDefault(zoneName);
    if (zone?.Modules == null) return;

    foreach (var module in zone.Modules)
    {
        var moduleModel = new ModuleModel
        {
            Module = module.ModuleName,
            Model = module,
            Locale = pageModel.Locale,
            SitemapPage = pageModel.SitemapPage
        };

        await html.RenderComponentAsync(
            module.ModuleName,
            new { moduleModel });
    }
}

Images

Use Agility's image API for optimized images:

@if (Model?.Image?.Url != null)
{
    <picture>
        <source media="(min-width: 1024px)"
                srcset="@(Model.Image.Url)?format=auto&w=1200" />
        <source media="(min-width: 768px)"
                srcset="@(Model.Image.Url)?format=auto&w=800" />
        <img src="@(Model.Image.Url)?format=auto&w=400"
             alt="@Model.Image.Label"
             loading="lazy"
             class="w-full h-auto" />
    </picture>
}

Preview Mode

Preview mode detection:

// Helpers/PreviewHelper.cs
public static bool IsPreviewMode(HttpContext context)
{
    // Always preview in development
    if (Environment.IsDevelopment())
        return true;

    // Check for preview cookie
    if (context.Request.Cookies.TryGetValue("agilitypreviewkey", out var cookieKey))
    {
        return ValidatePreviewKey(cookieKey);
    }

    // Check for preview query parameter
    if (context.Request.Query.TryGetValue("agilitypreviewkey", out var queryKey))
    {
        return ValidatePreviewKey(queryKey);
    }

    return false;
}

Tailwind CSS

Configuration in tailwind.config.js:

module.exports = {
  content: [
    './Pages/**/*.cshtml',
    './Views/**/*.cshtml',
    './ViewComponents/**/*.cs'
  ],
  theme: {
    extend: {
      colors: {
        primary: {
          500: '#6415FF',
          600: '#5011CC'
        },
        secondary: {
          500: '#243E63',
          600: '#1A2E4A'
        }
      }
    }
  },
  plugins: [
    require('@tailwindcss/typography')
  ]
}

Development

Run both Tailwind watch and .NET watch:

npm run dev & dotnet watch

Or separately:

# Terminal 1: Tailwind CSS watch
npm run dev

# Terminal 2: .NET watch
dotnet watch

Production Build

npm run build
dotnet publish -c Release

Deployment

Deploy to Azure

Deploy to Azure

Post-Deployment

  1. Configure webhooks in Agility CMS for cache invalidation
  2. Set up preview URLs
  3. Configure custom domain (optional)

See Deploy to Azure for detailed instructions.

Configuration

appsettings.json

{
  "AppSettings": {
    "InstanceGUID": "",
    "SecurityKey": "",
    "WebsiteName": "",
    "FetchAPIKey": "",
    "PreviewAPIKey": "",
    "Locales": "en-us",
    "ChannelName": "website",
    "CacheInMinutes": 5
  }
}

Local Development

Create appsettings.local.json (gitignored):

{
  "AppSettings": {
    "InstanceGUID": "your-guid",
    "SecurityKey": "your-key",
    "FetchAPIKey": "defaultlive.your-key",
    "PreviewAPIKey": "defaultpreview.your-key"
  }
}

Comparison with Blazor Starter

FeatureMVC StarterBlazor Starter
RenderingServer-onlyServer + optional SignalR
InteractivityJavaScript requiredC# via SignalR
Component ModelViewComponentRazor Component
File StructureSeparate .cs/.cshtmlSingle .razor
State ManagementViewData/TempDataComponent state
Learning CurveTraditional ASP.NETModern Blazor

Choose MVC if:

  • You prefer traditional ASP.NET patterns
  • Your team is experienced with Razor Pages
  • You don't need real-time interactivity
  • You want simpler deployment (no WebSockets required)

Choose Blazor if:

  • You want modern C# component patterns
  • You need interactive features without JavaScript
  • You're building a new project with no legacy constraints
  • Your team is comfortable with Blazor

Troubleshooting

ViewComponent Not Found

  1. Verify the class name matches the module name exactly
  2. Check the class inherits from ViewComponent
  3. Ensure it has an Invoke or InvokeAsync method

CSS Not Loading

  1. Run npm install
  2. Run npm run build
  3. Check output.css exists in wwwroot/css/

404 on Pages

  1. Verify sitemap is being fetched (check logs)
  2. Ensure ChannelName matches your Agility channel
  3. Check page is published in Agility CMS

Next Steps