Kinetq Development Group, LLC. - Introduction to LiquidPages: A Razor Pages–Style Framework for Liquid Templates in .NET

Introduction to LiquidPages

If you've ever enjoyed the developer experience of Razor Pages in ASP.NET but wished you could use the safer, designer-friendly Liquid templating language instead, then LiquidPages is the framework for you. LiquidPages is an open-source C# library from Kinetq that brings a familiar MVVM-style page model to Liquid templates—and it's designed to plug into virtually any .NET web server.

In this post, we'll take a tour of what LiquidPages is, why it exists, and how its building blocks fit together.

What Is LiquidPages?

LiquidPages is a middleware-based library that uses Fluid under the hood to parse and render Liquid templates. Liquid is a flexible and safe templating language originally created by Shopify, and it's a great fit for content-driven sites where templates may be edited by non-developers.

LiquidPages adopts an MVVM philosophy similar to Razor Pages—hence the name. Each page is paired with a page model that supplies a strongly-typed view model to the Liquid template at render time. The result is a clean separation between presentation (the .liquid file) and the data/logic that drives it (the C# page model).

Why Choose LiquidPages?

  • Safe templating – Liquid is sandboxed by design, making it ideal for templates that might be edited outside of source control.
  • Familiar patterns – If you know Razor Pages, you already know the mental model.
  • Web-server agnostic – Built as middleware that can run on EmbedIO, a custom HTTP listener, or anything else you can hand a request to.
  • Modular – Templates can live in any C# project across a solution, sourced from physical files, embedded resources, or a composite file provider.

Getting Started

Setting up LiquidPages is straightforward. Start by installing the NuGet package:

nuget add Kinetq.LiquidPages

Then register the services in your DI container with the provided helper:

services.AddLiquidPages();

Finally, wire up middleware for your web server of choice. There's a ready-to-use module for EmbedIO, and writing your own for any other server is simple.

Startup: Registering Routes and Filters

Before LiquidPages can render anything, it needs to know about your routes, filters, and types. This is done by injecting ILiquidStartup and calling the registration methods during your application's startup phase:

public class WebsiteStartup : IStartup
{
    private readonly ILiquidStartup _liquidStartup;

    public WebsiteStartup(ILiquidStartup liquidStartup)
    {
        _liquidStartup = liquidStartup;
    }

    public async Task Execute()
    {
        await _liquidStartup.RegisterPageModels();
        await _liquidStartup.RegisterFilters();
    }
}

Middleware: How Requests Become Pages

At the heart of the framework is ILiquidResponseMiddleware. It accepts a LiquidRequestModel—which encapsulates the route, query parameters, headers, and body—and returns a LiquidResponseModel ready to write to the output stream.

Here's the lifecycle of a request:

  1. The middleware iterates over registered routes, matching the request path against each route's RoutePattern (a regular expression).
  2. When a match is found, it optionally invokes the route's Execute delegate to build a view model.
  3. The Fluid engine parses the matched Liquid template, passing the view model (available in the template as view_model) plus any custom filters.
  4. If no route matches, it tries to serve static files (CSS, JS, images) from the default file provider.
  5. If neither a route nor a static file is found, it falls back to configured error routes (like a custom 404 page).

EmbedIO Out of the Box

For EmbedIO users, there's a dedicated module:

nuget add Kinetq.LiquidPages.EmbedIO
var liquidWebModule = new LiquidWebModule("/")
{
    LiquidResponseMiddleware = _liquidResponseMiddleware,
    ExcludedPaths = new Regex[]
    {
        new Regex("^/api/.*"),
        new Regex("^/static/.*")
    }
};

webServer.WithModule(liquidWebModule);

If you're using a different web server, just call HandleRequestAsync on the middleware from within your own request handler.

Routing: Two Flavors

LiquidPages gives you two ways to define routes.

1. Page Model–Based Routing (Default)

When you call RegisterPageModels, LiquidPages discovers your LiquidPageModel classes and wires up routes based on attributes that decorate them. This is the closest analog to Razor Pages and is the easiest place to start.

LiquidPageModel

The LiquidPageModel has three overriding members:

  • OnGetAsync

  • OnPostAsync

  • GetFileProvider

Also any properties defined on the page model will be made available in the .liquid template, exposed off of the view_model object passed into the templating engine.

Here is an example of the PageModel:

[LiquidPage("^/$", "Pages/Home.liquid")]
public class HomeModel : LiquidPageModel
{
    public string Title { get; set; } = "Welcome to Home";

    public override Task OnGetAsync(LiquidRequestModel request)
    {
        // Initialize your model properties here
        // This method is called when the page is requested

        return Task.CompletedTask;
    }
}

Here is an example of the template:

{% capture page_content %}
    <h1>{{ view_model.title }}</h1>
{% endcapture %}

{% include 'Layouts/default.liquid' %}

Notice that the page model needs to be decorated with [LiquidPage("^/$", "Pages/Home.liquid")], where the first argument is regex used to define the route pattern and the second argument is the path to the liquid template. With the default GetFileProvider implementation, the route of the path will always start at the csproj root.

LiquidErrorPage

Similarly to the LiquidPage, you can define a LiquidErrorPage that will be used based on HttpStatusCode Exceptions thrown during the rendering process:

[LiquidErrorPage(HttpStatusCode.NotFound, "ErrorPages/NotFound.liquid")]
public class NotFoundModel : LiquidPageModel
{
    public string Title { get; set; } = "Page Not Found";
    public string NotFoundMessage { get; set; } = "The page you are looking for was not found.";
    public override Task OnGetAsync(LiquidRequestModel request)
    {
        // Initialize your model properties here
        // This method is called when the page is requested
        return Task.CompletedTask;
    }
}

Notice the only difference here is the attribute used to decorate the page model, specifying the status code instead of a route pattern.

2. Custom Routing

For finer-grained control, you can implement ILiquidRoute directly. Each route returns a LiquidRoute object with three required pieces:

  • RoutePattern – A regular expression that matches the request path.
  • LiquidTemplatePath – The path to the Liquid template inside the route's FileProvider.
  • FileProvider – Where the template (and its related assets) lives.

You can also attach an optional Execute delegate to run server-side logic before rendering. Whatever it returns becomes the view_model inside the template.

public class AboutUsRoute : ILiquidRoute
{
    private readonly IIndexProvider _indexProvider;
    private readonly SiteConfig _siteConfig;
    private readonly ILiquidFileProvider _liquidFileProvider;

    public AboutUsRoute(IIndexProvider indexProvider, SiteConfig siteConfig, ILiquidFileProvider liquidFileProvider)
    {
        _indexProvider = indexProvider;
        _siteConfig = siteConfig;
        _liquidFileProvider = liquidFileProvider;
    }

    public async Task<LiquidRoute> GetRoute()
    {
        return new LiquidRoute
        {
            RoutePattern = new Regex("^/about-us$"),
            LiquidTemplatePath = "about.liquid",
            FileProvider = _liquidFileProvider.GetFileProvider(),
            Execute = async model =>
            {
                var webPage = await _indexProvider.Search()
                    .Must(() => new TermQuery(new Term(nameof(WebPage.SafeName), model.Route.TrimStart('/'))))
                    .SingleResult<WebPage>();

                return new WebPageViewModel
                {
                    Title = webPage.Hit.Name,
                    Body = webPage.Hit.Body,
                    SeoTitle = _siteConfig.SeoTitle + webPage.Hit.SeoTitle,
                    SeoDescription = webPage.Hit.SeoDescription
                };
            }
        };
    }
}

To switch from page-model routing to fully custom routing, simply call RegisterRoutes() instead of RegisterPageModels() during startup.

File Resolution: Embedded, Physical, or Composite

LiquidPages embraces .NET's IFileProvider abstraction, which means your Liquid templates and static assets can live almost anywhere. Each route specifies its own FileProvider, so templates can be centralized in a single project or distributed across modules.

A common pattern is to switch between physical files in development (for fast iteration) and embedded resources in production (for clean deployment):

public class LiquidFileProvider : ILiquidFileProvider
{
    public IFileProvider GetFileProvider()
    {
#if DEBUG
        string workingDirectory = Directory.GetCurrentDirectory();
        string projectDirectory = Directory.GetParent(workingDirectory).Parent.Parent.FullName;
        IFileProvider fileProvider = new PhysicalFileProvider(Path.Combine(projectDirectory, "Liquid"));
#else
        IFileProvider fileProvider = new EmbeddedFileProvider(typeof(LiquidFileProvider).Assembly, "Kinetq.Website.Liquid");
#endif
        return fileProvider;
    }
}

To use embedded resources, just include this in your .csproj:

<ItemGroup>
  <EmbeddedResource Include="**\*.liquid" />
</ItemGroup>

Tip: If you have additional static files like CSS or JavaScript, they'll also need to be marked as embedded resources for production builds.

The default LiquidPageModel already implements this debug/release split, so most projects can simply inherit and go.

If you want to use multiple file providers and combine them into one, you can use a composite file provider:

// 1. Define individual providers
var physicalProvider = new PhysicalFileProvider(Directory.GetCurrentDirectory());
var embeddedProvider = new EmbeddedFileProvider(Assembly.GetEntryAssembly());

// 2. Combine them into a CompositeFileProvider
var compositeProvider = new CompositeFileProvider(physicalProvider, embeddedProvider);

Custom Liquid Filters

Filters in LiquidPages follow the same pattern as routes: implement ILiquidFilter and return a LiquidFilter that pairs a filter name (used inside the template) with a delegate (the actual transformation logic).

Because filters are registered through DI, they can consume any service in your application—database contexts, search indexes, configuration, you name it.

Here's an example filter that returns the latest featured articles:

public class GetFeaturedArticlesFilter : ILiquidFilter
{
    private readonly IIndexProvider _indexProvider;

    public GetFeaturedArticlesFilter(IIndexProvider indexProvider)
    {
        _indexProvider = indexProvider;
    }

    public async Task<LiquidFilter> GetFilter()
    {
        return new LiquidFilter
        {
            Name = "get_featured_articles",
            FilterDelegate = GetFeaturedArticles
        };
    }

    private async ValueTask<FluidValue> GetFeaturedArticles(FluidValue input, FilterArguments arguments, TemplateContext context)
    {
        var blogPosts = (await _indexProvider.Search()
            .Must(() => new TermQuery(new Term(nameof(BlogPost.IsPublished), Boolean.TrueString)))
            .Sort(() => new SortField(nameof(BlogPost.PublishedDate), SortFieldType.INT64, true))
            .Paged(1, 4)
            .ListResult<BlogPost>()).Hits.Select(x => x.Hit).ToList();

        var featured = blogPosts
            .Select(bp => (FluidValue)new ObjectValue(new LinkViewModel
            {
                Title = bp.Name,
                Url = bp.Path
            }))
            .ToList();

        return new ArrayValue(featured);
    }
}

Use it in a template like any built-in Liquid filter:

{% assign featured = '' | get_featured_articles %}
{% for article in featured %}
  <a href="{{ article.Url }}">{{ article.Title }}</a>
{% endfor %}

Visual Studio Extension

To round out the developer experience, Kinetq provides a Visual Studio extension (Kinetq.LiquidPages.Extension) that adds:

  • Syntax highlighting for .liquid files (using HTML as the base, so both HTML and Liquid get proper coloring).
  • A built-in formatter powered by Prettier and the Shopify Liquid plugin (bound to Ctrl+Shift+X).
  • Quick commands to add new LiquidPage and LiquidErrorPage templates straight from the project context menu.

Before using the "Add LiquidPage" command, install the templates package:

dotnet new install Kinetq.LiquidPages.Templates

Wrapping Up

LiquidPages brings together the safety and elegance of Liquid templating with the productive, page-oriented patterns that .NET developers already love from Razor Pages. Whether you're building a content-driven website, a CMS-backed marketing site, or just need a templating layer for a small service, LiquidPages offers:

  • A Razor Pages–style MVVM development model
  • Flexible routing—from convention-based page models to fully custom routes and error pages
  • A modular file resolution story powered by IFileProvider
  • DI-friendly custom filters
  • Web-server agnostic middleware

If you're ready to give it a try, head over to the Liquid Pages Documentation and check out the docs. And don't forget to grab the Visual Studio extension to get the best authoring experience.

Happy templating! 🧪

Further Reading

Do you have more questions? Contact us

Contact us