How Components Work in .NET

Components (previously called modules) are the building blocks of pages in Agility CMS. They represent functional, reusable pieces of content that editors can add to page zones.

This guide explains how to create and use components in your .NET application.

What is a Component?

A component is a combination of:

  1. Content Model (in Agility CMS) - Defines the fields editors can fill in
  2. C# Model (in your app) - Strongly-typed class matching the content model
  3. View/Template (in your app) - Renders the component's HTML

For example, a "Rich Text Area" component might have:

  • Content Model: A single TextBlob field for HTML content
  • C# Model: RichTextArea class with a TextBlob property
  • View: Renders the HTML content with proper styling

Component Architecture

Blazor Components

In Blazor, components are .razor files that receive data as parameters:

Components/
└── AgilityComponents/
    ├── RichTextArea.razor
    ├── Heading.razor
    ├── TextBlockWithImage.razor
    └── PostsListing/
        ├── PostsListing.razor        # Server component
        └── PostsListingClient.razor  # Interactive component

MVC ViewComponents

In MVC, components use the ViewComponent pattern with separate class and view:

ViewComponents/
└── PageModules/
    ├── RichTextArea.cs
    ├── Heading.cs
    └── TextBlockWithImage.cs

Views/
└── PageModules/
    ├── RichTextArea.cshtml
    ├── Heading.cshtml
    └── TextBlockWithImage.cshtml

Creating a Component

Step 1: Define the Content Model

In Agility CMS, create a Module Definition with the fields your component needs. For example, a "Text Block with Image" component might have:

FieldTypeDescription
TitleTextHeading text
ContentHTMLRich text content
ImageImageFeatured image
PrimaryButtonLinkCall-to-action button

Step 2: Create the C# Model

Define a class matching your content model fields:

// Models/AgilityModels.cs
public class TextBlockWithImage
{
    public string? Title { get; set; }
    public string? Content { get; set; }
    public ImageAttachment? Image { get; set; }
    public Link? PrimaryButton { get; set; }
}

public class ImageAttachment
{
    public string? Url { get; set; }
    public string? Label { get; set; }
    public int? Height { get; set; }
    public int? Width { get; set; }
}

public class Link
{
    public string? Href { get; set; }
    public string? Target { get; set; }
    public string? Text { get; set; }
}

Step 3: Create the View

Blazor Implementation

@* Components/AgilityComponents/TextBlockWithImage.razor *@

<section class="py-16">
    <div class="container mx-auto px-4">
        <div class="flex flex-wrap items-center">
            @if (Fields?.Image?.Url != null)
            {
                <div class="w-full lg:w-1/2 mb-8 lg:mb-0">
                    <AgilityPic Image="@Fields.Image" FallbackWidth="600">
                        <AgilitySource Media="(min-width: 1024px)" Width="600" />
                        <AgilitySource Width="400" />
                    </AgilityPic>
                </div>
            }

            <div class="w-full lg:w-1/2 lg:pl-12">
                @if (!string.IsNullOrEmpty(Fields?.Title))
                {
                    <h2 class="text-3xl font-bold mb-4">@Fields.Title</h2>
                }

                @if (!string.IsNullOrEmpty(Fields?.Content))
                {
                    <div class="prose max-w-none">
                        @((MarkupString)Fields.Content)
                    </div>
                }

                @if (Fields?.PrimaryButton?.Href != null)
                {
                    <a href="@Fields.PrimaryButton.Href"
                       target="@(Fields.PrimaryButton.Target ?? "_self")"
                       class="inline-block mt-6 px-6 py-3 bg-primary-500 text-white rounded">
                        @(Fields.PrimaryButton.Text ?? "Learn More")
                    </a>
                }
            </div>
        </div>
    </div>
</section>

@code {
    [Parameter] public TextBlockWithImage? Fields { get; set; }
    [Parameter] public dynamic? CustomData { get; set; }
}

MVC Implementation

ViewComponent:

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

View:

@* Views/PageModules/TextBlockWithImage.cshtml *@
@model TextBlockWithImageFields

<section class="py-16">
    <div class="container mx-auto px-4">
        <div class="flex flex-wrap items-center">
            @if (Model?.Image?.Url != null)
            {
                <div class="w-full lg:w-1/2 mb-8 lg:mb-0">
                    <img src="@Model.Image.Url?w=600&format=auto"
                         alt="@Model.Image.Label"
                         loading="lazy" />
                </div>
            }

            <div class="w-full lg:w-1/2 lg:pl-12">
                @if (!string.IsNullOrEmpty(Model?.Title))
                {
                    <h2 class="text-3xl font-bold mb-4">@Model.Title</h2>
                }

                @if (!string.IsNullOrEmpty(Model?.Content))
                {
                    <div class="prose max-w-none">
                        @Html.Raw(Model.Content)
                    </div>
                }

                @if (Model?.PrimaryButton?.Href != null)
                {
                    <a href="@Model.PrimaryButton.Href"
                       target="@(Model.PrimaryButton.Target ?? "_self")"
                       class="inline-block mt-6 px-6 py-3 bg-primary-500 text-white rounded">
                        @(Model.PrimaryButton.Text ?? "Learn More")
                    </a>
                }
            </div>
        </div>
    </div>
</section>

Step 4: Register the Component

Blazor Registration

Add the component to the switch statement in AgilityComponent.razor:

@* Components/Shared/AgilityComponent.razor *@
@switch (ComponentName)
{
    case "RichTextArea":
        <RichTextArea Fields="@GetFields<RichTextArea>()" />
        break;

    case "Heading":
        <Heading Fields="@GetFields<Heading>()" />
        break;

    case "TextBlockWithImage":
        <TextBlockWithImage Fields="@GetFields<TextBlockWithImage>()" />
        break;

    // Add your new component here
    case "MyNewComponent":
        <MyNewComponent Fields="@GetFields<MyNewComponent>()" />
        break;

    default:
        <div class="bg-red-100 p-4 text-red-700">
            Unknown component: @ComponentName
        </div>
        break;
}

MVC Registration

MVC ViewComponents are discovered automatically by convention. Just ensure:

  1. The class name matches the module name (e.g., TextBlockWithImage)
  2. The class inherits from ViewComponent
  3. It has an Invoke or InvokeAsync method

Components with External Data

Some components need to fetch additional data beyond what's in the module fields. For example, a "Posts Listing" component needs to fetch blog posts.

Blazor Example

@* PostsListing.razor *@
@inject IAgilityService AgilityService

@if (posts != null)
{
    <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
        @foreach (var post in posts)
        {
            <article class="bg-white rounded shadow p-4">
                <h3>@post.Title</h3>
                <p>@post.Excerpt</p>
            </article>
        }
    </div>
}

@code {
    [Parameter] public PostsListing? Fields { get; set; }

    private List<Post>? posts;

    protected override async Task OnInitializedAsync()
    {
        posts = await AgilityService.GetPostsGraphQLAsync("en-us", take: 10);
    }
}

MVC Example

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

    public PostsListing(FetchApiService fetchApi)
    {
        _fetchApi = fetchApi;
    }

    public async Task<IViewComponentResult> InvokeAsync(ModuleModel moduleModel)
    {
        var posts = await _fetchApi.GetContentByGraphQL<Post>(
            query: @"{ posts { contentID fields { title slug excerpt } } }",
            objName: "posts",
            locale: moduleModel.Locale
        );

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

Interactive Components (Blazor Only)

Blazor supports interactive components that maintain state and respond to user actions via SignalR.

Server/Client Pattern

For components requiring interactivity, use a two-component pattern:

  1. Server Component - Fetches initial data during SSR
  2. Client Component - Handles interactive features
@* PostsListing.razor (Server) *@
@inject IAgilityService AgilityService

<PostsListingClient InitialPosts="@posts" />

@code {
    private List<Post>? posts;

    protected override async Task OnInitializedAsync()
    {
        posts = await AgilityService.GetPostsGraphQLAsync("en-us", take: 10);
    }
}
@* PostsListingClient.razor (Client) *@
@rendermode @(new InteractiveServerRenderMode(prerender: true))

<div class="posts-grid">
    @foreach (var post in allPosts)
    {
        <PostCard Post="@post" />
    }
</div>

@if (hasMore)
{
    <button @onclick="LoadMore" class="btn">Load More</button>
}

@code {
    [Parameter] public List<Post> InitialPosts { get; set; } = new();

    private List<Post> allPosts = new();
    private bool hasMore = true;

    protected override void OnInitialized()
    {
        allPosts = InitialPosts.ToList();
    }

    private async Task LoadMore()
    {
        var morePosts = await AgilityService.GetPostsGraphQLAsync(
            "en-us",
            take: 10,
            skip: allPosts.Count
        );

        allPosts.AddRange(morePosts);
        hasMore = morePosts.Count == 10;
    }
}

Best Practices

1. Keep Components Focused

Each component should do one thing well. If a component becomes complex, break it into smaller sub-components.

2. Use Null-Conditional Operators

Content fields can be null. Always use null-safe access:

// Good
@if (!string.IsNullOrEmpty(Fields?.Title))

// Bad - may throw NullReferenceException
@if (Fields.Title != "")

3. Provide Fallbacks

Handle missing content gracefully:

<h2>@(Fields?.Title ?? "Default Title")</h2>
<img src="@(Fields?.Image?.Url ?? "/images/placeholder.jpg")" />

4. Use Responsive Images

Leverage Agility's image API for optimized images:

@* Blazor *@
<AgilityPic Image="@Fields.Image" FallbackWidth="800">
    <AgilitySource Media="(min-width: 1024px)" Width="1200" />
    <AgilitySource Media="(min-width: 768px)" Width="800" />
    <AgilitySource Width="400" />
</AgilityPic>

@* MVC *@
<img src="@Fields.Image.Url?w=800&format=auto" />

5. Add Error Boundaries

Handle unknown components gracefully:

default:
    <div class="p-4 bg-yellow-100 text-yellow-800 rounded">
        <p><strong>Unknown component:</strong> @ComponentName</p>
        <p>Create a component for this module type.</p>
    </div>
    break;

Next Steps