惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

IT之家
IT之家
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
大猫的无限游戏
大猫的无限游戏
美团技术团队
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园_首页
MyScale Blog
MyScale Blog
N
Netflix TechBlog - Medium
I
InfoQ
Jina AI
Jina AI
Martin Fowler
Martin Fowler
Recent Announcements
Recent Announcements
量子位
月光博客
月光博客
罗磊的独立博客
雷峰网
雷峰网
The Cloudflare Blog
V
V2EX
小众软件
小众软件
人人都是产品经理
人人都是产品经理
博客园 - Franky
T
Tailwind CSS Blog
有赞技术团队
有赞技术团队
S
SegmentFault 最新的问题

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Introduction to LiquidPages
Kinetq · 2026-05-30 · via DEV Community

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