LiquidResponse
Rails controller and view helpers for rendering Liquid templates — designed for Shopify App Proxy and any Rails app that serves Liquid markup to a storefront.
How it works
Shopify's App Proxy lets your Rails app respond to storefront requests with Liquid templates, which Shopify then renders in the context of the live theme. LiquidResponse handles:
- Registering the
liquidMIME type with Rails - Telling Rails to prefer
liquidoverhtmlfor app proxy requests - Stripping
turbo-streamfrom the Accept header (Turbo injects it everywhere; the proxy can't use it) - View helpers for building valid Liquid
{% render %},{% assign %}, and{% capture %}tags from ERB
Installation
Add to your Gemfile:
gem "liquid_response"
Setup
1. Register the MIME type
# config/initializers/liquid_response.rb
require "liquid_response"
LiquidResponse.register_liquid_mime_type!
2. Include the controller helper
class AppProxyController < ApplicationController
include LiquidResponse::ControllerHelper
allow_liquid_rendering_when_html_is_requested
end
allow_liquid_rendering_when_html_is_requested installs a before_action that:
- Strips
turbo-streamfromrequest.formats - Prepends
Mime[:liquid]so Rails picks the.liquid.erbtemplate
3. Create .liquid.erb views
Rails will look for app/views/posts/show.liquid.erb when the request format resolves to liquid. Write Liquid tags using the view helpers:
<%# app/views/posts/show.liquid.erb %>
<%= render_liquid "my-app--post--card", title: @post.title, published: @post.published? %>
This outputs:
{% render "my-app--post--card", title: "Hello World", published: true %}
View helpers
All helpers are available in views once LiquidResponse::ControllerHelper is included (it auto-includes LiquidResponse::ViewHelper as a helper module).
render_liquid(snippet_name, **assigns)
Emits a Liquid {% render %} tag with the given assigns.
<%= render_liquid "my-app--product--card",
title: @product.title,
price: @product.price,
available: @product.available? %>
{% render "my-app--product--card", title: "Widget", price: 9.99, available: true %}
assign_liquid(variable_name, value)
Emits a {% assign %} tag. Useful for hoisting a value to Liquid scope before passing it to a snippet.
<%= assign_liquid :greeting, "Welcome" %>
{% assign greeting = "Welcome" %}
liquid_variable(name)
Returns a LiquidVariable object — a placeholder that renders as a bare Liquid variable name (no quotes). Pass it as an assign value when the variable already exists in Liquid scope.
{% assign products = collections.all.products %}
<%= render_liquid "my-app--product--list", items: liquid_variable(:products) %>
{% render "my-app--product--list", items: products %}
liquid_capture(name, &block)
Wraps the block output in {% capture %} / {% endcapture %}.
<%= liquid_capture(:sidebar) do %>
<p>Some sidebar content</p>
<% end %>
{% capture sidebar %}<p>Some sidebar content</p>{% endcapture %}
Passing complex values
Strings, numbers, booleans, nil, dates, times, symbols
Converted to their JSON equivalents automatically.
<%= render_liquid "my-app--order",
id: 42,
status: :pending,
placed_on: Date.today,
gift: false,
note: nil %>
{% render "my-app--order", id: 42, status: "pending", placed_on: "2026-05-26", gift: false, note: null %}
Hashes (nested objects)
Each Hash assign is promoted to an intermediate {% assign %} variable so Liquid receives it as an object.
<%= render_liquid "my-app--address--card",
address: {
street: @address.line1,
city: @address.city,
zip: @address.zip
} %>
{% assign h_address_a1b2c = null | default: street: "Via Roma 1", city: "Roma", zip: "00100" %}
{% render "my-app--address--card", address: h_address_a1b2c %}
Nesting works recursively:
<%= render_liquid "my-app--subscription",
plan: {
name: "Monthly",
interval: { unit: "week", count: 4 }
} %>
{% assign h_interval_x = null | default: unit: "week", count: 4 %}
{% assign h_plan_y = null | default: name: "Monthly", interval: h_interval_x %}
{% render "my-app--subscription", plan: h_plan_y %}
Arrays
Arrays are converted to indexed hashes (Liquid has no native array literal):
<%= render_liquid "my-app--tag--list", tags: ["sale", "new"] %>
{% assign h_tags_z = null | default: 0: "sale", 1: "new" %}
{% render "my-app--tag--list", tags: h_tags_z %}
Custom objects — as_liquid protocol
Any object that implements as_liquid is serialized through it. The method must return a Hash, a scalar, or another as_liquid-capable object.
class Product
def as_liquid
{ "title" => title, "price" => price.to_f, "available" => available? }
end
end
<%= render_liquid "my-app--product--card", product: @product %>
LiquidResponse ships with as_liquid on String, Numeric, TrueClass, FalseClass, NilClass, Date, Time, Symbol, Array, Hash, and Data — all the types you use day-to-day.
ActiveModel / Serializable objects
Objects that respond to serializable_hash are converted automatically:
<%= render_liquid "my-app--user--card", user: current_user %>
Mixing types
Everything composes:
<%= render_liquid "my-app--portal",
customer_name: @customer.name,
logged_in: true,
items: liquid_variable(:cart_items),
address: {
city: @shipping_address.city,
zip: @shipping_address.zip
} %>
liquid_routes (app-level, not in the gem)
The helper liquid_routes — which exposes Rails route helpers as a Liquid-usable hash — is intentionally not part of this gem. It depends on your app's routing conventions. Define it yourself in your base controller and expose it via helper_method:
class AppProxyController < ApplicationController
include LiquidResponse::ControllerHelper
allow_liquid_rendering_when_html_is_requested
helper_method :liquid_routes
private
def liquid_routes
Rails.application.routes.routes
.filter_map do |route|
next unless route.name&.start_with?("my_app_proxy_")
[route.name.sub("my_app_proxy_", "") + "_url", route.path.spec.to_s.sub(/\([^)]*\)/, "")]
end
.sort.to_h
end
end
Then in views:
<%= render_liquid "my-app--nav", routes: liquid_routes %>
Development
bundle install
bundle exec rake test
License
MIT