Liquid Pages
Liquid pages is a library that uitlizes Fluid under the hood to create middleware that can be used with any web server to render liquid templates. It has a MVVM philosophy similar to Razor Pages (hence the name Liquid Pages).
Setup
Start by adding the nuget package:
nuget add Kinetq.LiquidPages
Next add all of the Liquid Pages services via this helper method:
services.AddLiquidPages()
Next you'll add middleware, which could be different based on the web server you are using. Currently there is only one build in middleware for EmbedIO (although building your own is very simple and detailed below).
Startup
For Liquid Pages to properly function Routes, Filters and Types need to be registered before attempting to render a template. In order to do that there are two interfaces you'll need to inject:
public class WebsiteStartup : IStartup
{
private readonly ILiquidRegisteredTypesManager _liquidRegisteredTypesManager;
private readonly ILiquidStartup _liquidStartup;
public WebsiteStartup(
ILiquidRegisteredTypesManager liquidRegisteredTypesManager,
ILiquidStartup liquidStartup)
{
_liquidRegisteredTypesManager = liquidRegisteredTypesManager;
_liquidStartup = liquidStartup;
}
public async Task Execute()
{
await _liquidStartup.RegisterRoutes();
await _liquidStartup.RegisterFilters();
_liquidRegisteredTypesManager.RegisterType<NavItemViewModel>();
_liquidRegisteredTypesManager.RegisterType<NavViewModel>();
_liquidRegisteredTypesManager.RegisterType<LinkViewModel>();
_liquidRegisteredTypesManager.RegisterType<TagViewModel>();
_liquidRegisteredTypesManager.RegisterType<CategoryItemViewModel>();
_liquidRegisteredTypesManager.RegisterType<BlogPostViewModel>();
_liquidRegisteredTypesManager.RegisterType<CategoryViewModel>();
_liquidRegisteredTypesManager.RegisterType<WebPageViewModel>();
_liquidRegisteredTypesManager.RegisterType<ContactViewModel>();
_liquidRegisteredTypesManager.RegisterType<LegalPageViewModel>();
_liquidRegisteredTypesManager.RegisterType<ArticleItemViewModel>();
_liquidRegisteredTypesManager.RegisterType<ArticlesViewModel>();
_liquidRegisteredTypesManager.RegisterType<SubscriptionsViewModel>();
_liquidRegisteredTypesManager.RegisterType<SearchViewModel>();
}
}
Middleware
EmbedIO
nuget add Kinetq.LiquidPages.EmbedIO
var liquidWebModule = new LiquidWebModule("/")
{
LiquidResponseMiddleware = _liquidResponseMiddleware,
ExcludedPaths = new Regex[]
{
new Regex("^/api/.*"),
new Regex("^/static/.*")
}
};
webServer.WithModule(liquidWebModule);
Custom Middleware
Otherwise if there is no existing middleware for your webserver, the implementation would still be simple and look something like this:
try
{
var liquidRequest = new LiquidRequestModel()
{
Route = request.Url.AbsolutePath,
QueryParams = request.Url.Query.GetQueryParams(),
Headers = request.Headers
};
if (request.HasEntityBody)
{
using var reader = new StreamReader(request.InputStream, Encoding.UTF8);
liquidRequest.Body = await reader.ReadToEndAsync();
}
var responseModel =
await LiquidResponseMiddleware.HandleRequestAsync(liquidRequest);
response.ContentLength64 = responseModel.Content.Length;
response.ContentType = responseModel.ContentType;
response.StatusCode = responseModel.StatusCode;
await response.OutputStream.WriteAsync(responseModel.Content);
}
catch (Exception ex)
{
response.StatusCode = 500;
byte[] errorBuffer = Encoding.UTF8.GetBytes($"Internal Server Error: {ex.Message}");
response.ContentLength64 = errorBuffer.Length;
response.ContentType = "text/html";
await response.OutputStream.WriteAsync(errorBuffer);
}
finally
{
response.Close();
}
}
Decentralized Templates
Liquid Pages embraces modularity by allowing templates to be defined in any C# project using the .NET IFileProvider abstraction. Each route can be backed by its own file provider, enabling a clean separation of concerns—whether you centralize all templates or distribute them across modules.
The library leverages the
IFileProviderinterface, making it possible to source templates from physical files, embedded resources, or even cloud storage.Every
LiquidRoutemust specify aFileProviderthat points to the directory containing its Liquid templates and associated assets (e.g., CSS, JavaScript, images).When resolving a route, Liquid Pages looks for partial templates and static files exclusively within that route’s
FileProvider, keeping each route self‑contained.You can either configure a single file provider for the whole application or adopt a truly modular approach with a separate provider per route.
The example below shows a custom
ILiquidFileProviderthat switches between physical files during development and embedded resources in production:
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;
}
}
Routing
Routing in Liquid Pages follows a familiar pattern inspired by Razor Pages. You define routes by implementing the ILiquidRoute interface, which returns a LiquidRoute object containing all the information needed to match an incoming request and produce a response.
Routes are classes that implement
ILiquidRouteand provide aLiquidRoutevia theGetRoute()method.A
LiquidRouterequires three mandatory properties:RoutePattern: a regular expression that tests the request path.LiquidTemplatePath: the path to the Liquid template file inside the specifiedFileProvider.FileProvider: supplies the template and any related assets.
An optional
Executedelegate can be attached to run server‑side logic before rendering. It receives aLiquidRequestModeland returns a view model, which becomes available in the template asview_model.The following example, taken from the Liquid Pages GitHub repository, demonstrates a route that matches
/about-us, fetches data from an index, and returns aWebPageViewModel:
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>();
string seoDescription = webPage.Hit.SeoDescription;
string seoTitle = !string.IsNullOrEmpty(webPage.Hit.SeoTitle)
? webPage.Hit.SeoTitle
: webPage.Hit.Name;
seoTitle = _siteConfig.SeoTitle + seoTitle;
return new WebPageViewModel()
{
Title = webPage.Hit.Name,
Body = webPage.Hit.Body,
SeoDescription = seoDescription,
SeoTitle = seoTitle
};
})
};
}
}
Filters
Custom Liquid filters are created in a similar fashion to routes: implement ILiquidFilter and return a LiquidFilter object that pairs a filter name with a delegate. This design keeps filter logic encapsulated and testable while allowing full dependency injection.
Filters implement the
ILiquidFilterinterface and define aGetFilter()method that returns aLiquidFilter.The
LiquidFiltercontains aName(the identifier used in templates) and aFilterDelegate—the actual function that performs the transformation.The
FilterDelegatecomes from the Fluid library and must return a value derived fromFluidValue(e.g.,ArrayValue,ObjectValue,StringValue).Because filters are registered in the DI container, they can consume any service, such as a search index or database context.
The example below shows a filter that retrieves featured articles and returns them as an array of view models:
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)
{
string filterInput = input.ToStringValue();
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 featuredBlogPosts = new List<FluidValue>();
foreach (var blogPost in blogPosts)
{
var blogPostViewModel = new LinkViewModel()
{
Title = blogPost.Name,
Url = $"{blogPost.Path}"
};
featuredBlogPosts.Add(new ObjectValue(blogPostViewModel));
}
return new ArrayValue(featuredBlogPosts);
}
}
Error Routes
Liquid Pages makes it easy to serve custom error pages by introducing the ILiquidErrorRoute interface. This works just like a normal route but is associated with a specific HTTP status code, allowing you to design branded 404, 500, or other error pages.
Error routes implement
ILiquidErrorRoute, which extends the route concept with aStatusCodeproperty.The
GetRoute()method returns aLiquidRoute—typically containing aFileProviderand aLiquidTemplatePath—that will be used when an error with that status code occurs.The
StatusCodeproperty tells the middleware which HTTP error this route handles (e.g., 404 for Not Found).You can omit the
Executedelegate if the error page does not require dynamic data.The example below, taken from the Liquid Pages repository, demonstrates a simple 404 error route:
public class NotFoundRoute : ILiquidErrorRoute
{
private readonly ILiquidFileProvider _liquidFileProvider;
public NotFoundRoute(ILiquidFileProvider liquidFileProvider)
{
_liquidFileProvider = liquidFileProvider;
}
public async Task<LiquidRoute> GetRoute()
{
return new LiquidRoute
{
FileProvider = _liquidFileProvider.GetFileProvider(),
LiquidTemplatePath = "status_codes/404.liquid"
};
}
public int StatusCode => 404;
}
Liquid Response Middleware
The ILiquidResponseMiddleware is the engine that ties everything together. After injecting it into your application, you call HandleRequestAsync with a LiquidRequestModel that encapsulates the incoming request. The middleware then orchestrates route matching, data retrieval, template parsing, and response generation, returning a LiquidResponseModel ready to be written to the output stream.
ILiquidResponseMiddlewareis the core service; itsHandleRequestAsyncmethod accepts aLiquidRequestModelcontaining the route, query parameters, body, and headers.The middleware iterates through all registered routes, matching the request path against each
RoutePattern(regex).When a match is found, it optionally invokes the route’s
Executedelegate to obtain a view model.The middleware then parses the Liquid template using the Fluid engine, passing the view model (accessible as
view_model) and any custom filters registered in the system.If no route matches, it attempts to serve static files (e.g., CSS, images) from the default file provider.
If neither a route nor a static file is found, it falls back to any configured error routes (such as the 404 route described above) to produce an appropriate response.
The final output is a byte array with the correct MIME type and HTTP status code, packaged in a
LiquidResponseModel.This design makes the middleware adaptable to any web server, as demonstrated in the generic integration example at the beginning of this document. For a detailed look at the implementation, see the
LiquidResponseMiddleware.csfile in the repository.
