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 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.
- Render anywhere – In addition to the request pipeline, templates can be rendered directly to HTML strings via
IHtmlRendererfor emails, background jobs, and API/SocketIO responses.
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 Page Models, Filters, and File Providers
Before LiquidPages can render anything, it needs to know about your page models, filters, and file providers. This is done by resolving 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()
{
_liquidStartup.RegisterPageModels();
_liquidStartup.RegisterFilters();
string workingDirectory = Directory.GetCurrentDirectory();
_liquidStartup.RegisterFileProvider("/", new PhysicalFileProvider(workingDirectory));
}
}
RegisterFileProvider lets you tell LiquidPages where to look for templates and static assets associated with a particular path prefix. You can register as many providers as you need to support modular projects.
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:
- The middleware iterates over registered page models, matching the request path against each page model's
RoutePattern(a regular expression). - When a match is found, it invokes the page model's
OnGetAsyncorOnPostAsyncto build a view model. - The Fluid engine parses the matched Liquid template, passing the view model (available in the template as
view_model) plus any custom filters. - If no page model matches, it tries to serve static files (CSS, JS, images) from the registered file providers.
- If neither a page model nor a static file is found, it falls back to configured error pages (like a custom 404 page).
AspNetCore, EmbedIO & GenHTTP Out of the Box
For AspNetCore Web Framework users you simple have to add the companion package:
dotnet add package Kinetq.LiquidPages.AspNetCore
Then register LiquidPages services, initialize startup registrations, and map LiquidPages endpoints:
using Kinetq.LiquidPages.AspNetCore;
using Kinetq.LiquidPages.Helpers;
using Kinetq.LiquidPages.Interfaces;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLiquidPages(typeof(Program).Assembly);
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var startup = scope.ServiceProvider.GetRequiredService<ILiquidStartup>();
await startup.RegisterPageModels();
await startup.RegisterFilters();
string workingDirectory = Directory.GetCurrentDirectory();
startup.RegisterFileProvider("/", new PhysicalFileProvider(workingDirectory));
}
app.UseLiquidPagesErrorHandling();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapLiquidPages();
});
await app.RunAsync();
Documentation for the rest of the middelware can be found here: https://www.kinetq.com/docs/open-source-software/middleware-setup
Page Model–Based Routing
LiquidPages uses a convention-based, page-model approach to routing—the same mental model that makes Razor Pages so productive. When you call RegisterPageModels, LiquidPages discovers your LiquidPageModel classes and wires up routes based on attributes that decorate them.
LiquidPageModel
The LiquidPageModel has three overridable members:
OnGetAsyncOnPostAsync
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 a page model:
[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;
}
}
And the corresponding template:
{% capture page_content %}
<h1>{{ view_model.title }}</h1>
{% endcapture %}
{% include 'Layouts/default.liquid' %}
Notice that the page model is decorated with [LiquidPage("/", "Pages/Home.liquid")], where the first argument is the regex used to define the route pattern and the second argument is the path to the Liquid template. With the default GetFileProvider implementation, the template path will always start at the csproj root.
LiquidErrorPage
Similarly to 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)
{
return Task.CompletedTask;
}
}
The only difference here is the attribute used to decorate the page model—specifying the status code instead of a route pattern.
Rendering Templates Outside the Pipeline with IHtmlRenderer
The middleware is the most common way to render Liquid templates, but it isn't the only option. Sometimes you need to render a template into an HTML string outside of the request/response pipeline—for example, when generating HTML for emails, server-sent events, AI chat responses, or background jobs.
For these cases, LiquidPages exposes an injectable service called IHtmlRenderer (registered automatically by AddLiquidPages()).
IHtmlRenderer takes:
- A
RenderModelcontaining the view model to be exposed to the template (accessible inside the template asview_model). - A
LiquidRoutethat tells the renderer where the template lives. Only two properties are required:FileProvider– TheIFileProviderthat contains your Liquid templates and assets.LiquidTemplatePath– The path to the template, relative to the file provider's root.
Because it bypasses the middleware entirely, there is no routing or request matching involved—just template + data → HTML.
Here's an example that renders a chat messages view to HTML:
var renderModel = new RenderModel
{
ViewModel = new ChatMessagesViewModel()
{
Messages = resultingTextMessages.Select(r => new ChatMessageViewModel
{
Role = r.Role.ToString(),
Content = r.Text
}).ToList()
}
};
var fileProvider = _liquidFileProvider.GetFileProvider(typeof(ModuleServices).Assembly);
var liquidRoute = new LiquidRoute()
{
FileProvider = fileProvider,
LiquidTemplatePath = templatePath
};
return await _htmlRenderer.RenderHtml(renderModel, liquidRoute);
Tip:
RenderHtmluses all currently registered types, which are needed if you want to pass POCO objects to the template. If a type isn't already registered, make sure to register it on startup with theILiquidRegisteredTypesManager.
File Resolution: Embedded, Physical, or Composite
LiquidPages embraces .NET's IFileProvider abstraction, which means your Liquid templates and static assets can live almost anywhere. 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 combine multiple file providers 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 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
.liquidfiles (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
- Convention-based page model routing with attribute-driven page and error pages
- A modular file resolution story powered by
IFileProvider - DI-friendly custom filters
- Web-server agnostic middleware
- HTML rendering outside the request pipeline via
IHtmlRenderer
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! 🧪
