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:
- The middleware iterates over registered routes, matching the request path against each route's
RoutePattern(a regular expression). - When a match is found, it optionally invokes the route's
Executedelegate to 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 route matches, it tries to serve static files (CSS, JS, images) from the default file provider.
- 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'sFileProvider.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
.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
- 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! 🧪
