LayerKick LayerKick Journal
Journal How It Works 63ms TTFB Join Waitlist →
Waitlist →
Engineering / Entry №04 / ★ Flagship

We compile Liquid to TSX.

How LayerKick turns a merchant's whole Shopify theme (sections, blocks, snippets, filters) into typed components that render at the edge. A parser, an IR, and 229 generated files.

Craig Ruks, Founder · March 10, 2026 · 8 min read · AI-drafted, founder-edited
229
Generated .astro files
34
Sections transpiled
228/228
Components at node parity
3
Days to first full theme

To serve a Shopify storefront from the edge, you have to be able to actually render it there. By “the edge” I mean Cloudflare’s network of data centers, the ones physically close to whoever is shopping. Serving a page from there instead of from a single origin an ocean away is most of where the speed comes from, but it only works if you can produce the page at that location, on demand, with that specific shopper’s data.

Shopify themes are written in Liquid, a templating language that really only Shopify’s servers speak fluently. That’s the catch. Most performance tools hit this wall and settle for proxying the finished HTML: let Shopify render the page, then cache and forward the result. It works, but you’re still tied to Shopify’s render for anything dynamic, and you don’t really own the page. I wanted to go a step further and compile the theme ahead of time into TSX components (typed, React-style templates) that render anywhere JavaScript runs, including at the edge.

This entry covers that pipeline: parsing Liquid into a syntax tree, lowering it through an intermediate representation, and generating a whole component library from a production theme. Then checking it, node-for-node, against Shopify’s own render, because a transpiler you can’t trust is worse than no transpiler at all.

Parse, don’t pattern-match

So the first decision was to lean on a real parser. A parser reads the source and builds an AST, an abstract syntax tree, which is just a structured representation of what the template means rather than the raw characters it’s made of. The tempting shortcut is to skip that and match patterns with regular expressions: find {% if %}, find {% endif %}, swap them for something. That’s a graveyard.

The reason it’s a graveyard is that Liquid nests, and regex doesn’t really understand nesting. A {% raw %} block turns Liquid processing off for everything inside it, so a naive matcher will happily rewrite tags that were meant to be left alone as literal text. Filter chains like product.title | upcase | truncate: 20 compose left to right and have to be understood as a sequence, not a string to search and replace. Every one of these will defeat a pattern-matcher eventually, usually in production, on a theme you didn’t write.

So we build on Shopify’s own liquid-html-parser. That choice is deliberate. It gives us a lossless AST of both the Liquid and the HTML it’s woven through, and because it’s Shopify’s parser, our starting understanding of a theme is the same as theirs by definition. If we later diverge from Shopify’s rendering, it can’t be because we read the template differently; it has to be something we did downstream, which is a place we can put a test.

Using Shopify’s own parser means our view of a theme is definitionally the same as Shopify’s. Divergence can only be introduced downstream, where we can test for it.

What I didn’t expect was how much work it would take to get real themes through that parser. It was built for editor tooling, so it’s strict. Hand it HTML that any browser would quietly repair, like a <button> that never closes before its </form> does, and it rejects the whole file. Production themes are full of exactly that. So in front of the parse sits a recovery pass: when the parser refuses a file over an unclosed element, we insert the missing closing tag the way an HTML5 parser would have, and parse again. The AST is the right foundation, but on its own it didn’t do enough; it needed extra passes just to accept the inputs. The current fight, as I write this, is themes that compute the HTML tag itself in Liquid, <{% if heading %}h2{% else %}div{% endif %}>, a shape the parser can’t represent at all. That one’s mid-surgery.

From the AST, three transformers do the lowering. “Lowering” is compiler-speak for translating from a higher-level representation down to a more concrete one, step by step, so no single pass has to understand everything at once:

// The expression builder, simplified. Liquid expressions become TS
// with Liquid's nil-semantics preserved, not JavaScript's.
buildExpression(`product.compare_at_price | money`);
// → money(ctx.get(["product", "compare_at_price"]))

// The logic transformer: control flow. if/unless/case/for/
// assign/capture/render, each with Liquid scoping rules.

// The template transformer: Liquid+HTML → generic TSX,
// section schemas → typed settings interfaces.

Those three transformers carry most of the real difficulty, because Liquid’s semantics aren’t JavaScript’s, and the differences are exactly where a naive transpiler dies. Three examples, since they’re the ones that actually bit.

First, nil. In Liquid a missing value is nil, and nil flows through a filter chain instead of throwing. nothing | money gives you an empty result, not a crash. The direct JavaScript equivalent would blow up on the missing property. So the expression builder has to reproduce Liquid’s forgiving behavior, which is why the generated code routes through a ctx.get([...]) helper instead of plain property access.

Second, forloop. A Liquid for loop exposes a forloop object inside its body, with index, first, last, and so on, and themes lean on it constantly for things like striping alternate rows. That state has to be built and scoped correctly per loop, including nested loops, or you get subtle off-by-one bugs in the markup that nobody notices until a customer does.

Third, truthiness. Liquid follows Ruby here, not JavaScript. An empty string is truthy in Liquid; only nil and false are falsy. In JavaScript an empty string is falsy, so a literal translation of {% if product.title %} would quietly do the wrong thing on an edge case. Every one of these rules lives in one transformer, with a regression fixture behind it, so the rule is written down once and checked forever after.

The assembler

Above the transformers sits an assembler that walks a whole theme and emits a component library. This is the part that turns a pile of translated fragments into something coherent you can actually render as a page.

Sections and blocks (the reusable, merchant-configurable pieces of a Shopify theme) become components with typed settings. The types come straight from the {% schema %} JSON that every section carries, so if a section declares a heading text setting and a columns range, the generated component’s props reflect that, and TypeScript complains if we wire it up wrong. Snippets, the smaller shared partials, become shared partials on our side too.

Then there are the filters. Liquid ships around 60 standard filters (money, date, truncate, img_url, and the rest), and each one carries edge cases Shopify has accumulated over years. We can’t hand-wave those, so they come from a hand-written runtime library that keeps Shopify’s behavior intact, edge cases and all. It’s tedious work, but it’s the kind of tedium that either matches or it doesn’t, and you find out right away which.

StageInputOutput
Parser.liquid theme filesLossless Liquid+HTML AST
TransformersASTTyped expression / logic / template IR
AssemblerIR + {% schema %}229 .astro components (Dawn theme to start)
RuntimeStorefront requestRendered page, per-request context

The first full run against a production theme generated 229 files, 34 sections plus every block and snippet, in one pass, over about three days. That theme was Dawn, Shopify’s reference theme, and starting there was deliberate, because half the ecosystem descends from it. Everything in Threshold, the theme the first version of this transpiler was built for, was already supported, and support for additional themes is being built out now. That “in one pass” part is the payoff of doing the parser properly: once the pipeline is right, a new theme is mostly just input. Getting from “it generates” to “it generates correctly” is a different problem, and it’s where the regression discipline below comes in.

Proving parity

The transpiler’s contract isn’t “roughly the same page.” It’s DOM parity: render the same template with the same data on Shopify and on LayerKick, diff the resulting trees, and fail the build if they diverge.

DOM parity means the rendered structure matches, element for element, not just that the page looks about right. That’s a stricter bar than a screenshot comparison, and it’s the correct one, because a small structural difference can break a theme’s own JavaScript or CSS selectors even when the pixels happen to line up at first glance.

Every transpiler fix ships with a fixture: a minimal .liquid input and the expected output, snapshot-tested. Keeping the fixtures minimal is the point. When one breaks, it tells you exactly which rule regressed, instead of burying the signal in a whole page of unrelated markup.

When a real theme surfaces some new Liquid edge case (and real themes are basically made of edge cases) the fix lands together with a fixture that pins it forever. So the suite grows to match the actual weirdness of the themes we’ve run, rather than the weirdness I imagined up front. It matters enough that the first hard rule in this repo’s agent contract is to default to transpiler/IR fixes only, and the third requires a regression fixture and snapshot with every one.

On top of the fixtures, the parity suite does the end-to-end check: it renders real templates against Shopify’s actual output, section by section, with pixel-level diffs layered on for good measure. The fixtures catch the specific rules; the end-to-end suite catches the combinations you never thought to write a fixture for.

That parity bar is what makes the rest of LayerKick possible. Serving a page from the edge is only worth anything if what you serve is indistinguishable from what Shopify would have served, and the only way I’m comfortable claiming that is to check it on every deploy rather than trust that it held.

What’s next in the log

The transpiled components are the raw material. The next entries cover what the edge actually does with them: the shim that serves a warm page in single-digit milliseconds, and why cache invalidation, the famous hard problem, is where most of the engineering ends up living.

If you're curious
See it on your own store.

LayerKick layers onto your existing Shopify theme and serves it from Cloudflare's edge. If anything goes wrong, traffic passes through to Shopify like we were never there. The fastest way to understand it is to watch it run on your own storefront, and the waitlist is the way in.

Join the waitlist → $200/mo beta · billing starts two weeks after signup
← Previous entry
How this journal gets written.
End of the log →
Back to all entries