Funnelish pages pull dynamic content from two data contexts. Which context holds your data decides the syntax you write, so start here before writing anything.
Server-side data (Liquid)
-
When it renders: on our servers, while the page is being served. The output is in the HTML source, so search engines read it.
-
Syntax: Liquid, written as
{{ }}for output and{% %}for logic. -
Products: names, titles, prices, images, subscription and trial terms, order bump flags.
-
Blog content: the current post, post collections, categories.
-
Page metadata: title, URL, SEO fields.
-
Customer portal products: the portal’s catalog and its variants.
Covered in Part 1.
Client-side data (x-attributes)
-
When it renders: in the visitor’s browser, after the page loads. Not in the HTML source.
-
Syntax: x-attributes on elements, like
x-textandx-show. Not{{ }}. -
Customer details: name, email, phone, addresses, custom fields, as the visitor enters them.
-
The live order: selected products, purchased products, the running total.
-
Visit context: URL parameters and UTM tags.
-
Portal subscriptions: status, next billing date, items, payment method.
Covered in Part 2.
The most common mistake: writing
{{ customer.first_name }}in Liquid. Customer data doesn’t exist when the page is rendered, so that outputs nothing. Customer, order and subscription data are client-side.
New here? Read Data Binding first. The picker handles most cases with no code at all.
Part 1: Server-side data (Liquid)
Liquid runs on our servers when the page is served, so its output is in the HTML before the browser sees it. That makes it the right context for anything search engines should read.
Where you write it
Liquid works anywhere text can go, across all page types: funnels, stores, blogs and customer portals, including inside shared components.
-
Any text on the page: headlines, paragraphs, button labels.
Get {{ product.title }} todayinside a button works exactly as you’d expect. -
Custom HTML elements (recommended): for anything beyond a quick inline value, write Liquid in a custom HTML element. Text elements run through the rich-text editor, which can inject formatting or auto-link dotted paths inside your expression and quietly break it. A custom HTML element is plain code, nothing interferes.
-
Custom code binding slots: any binding slot in the Data tab accepts Liquid as custom code, which is how you put a computed value behind a normal, styleable element.
Automation emails and SMS are not Liquid. They use a separate engine with their own field inserter.
Syntax
{{ product.name }} output a value
{% if product.image_url %} logic, outputs nothing itself
Dot for properties, brackets for array items, pipe for filters chained left to right. Variable names are case-sensitive.
{{ allProducts[0].title }}
{{ blogPost.published_by_author_on | date: "%B %d, %Y" | upcase }}
Funnel pages
Available on checkout, upsell, downsell and store pages.
| Variable | Type | Description |
|---|---|---|
allProducts |
array | Every product on the current page, in display order |
currentProduct |
object | The single product a container is bound to. Exists only inside a container with a Product binding |
Page |
object | The current page’s metadata |
Title |
text | The page’s SEO title |
Component |
object | Inside a shared component: that component’s name and id |
Page fields
| Field | Type | Description |
|---|---|---|
title |
text | SEO title of the page |
name |
text | Internal step name from your funnel |
id |
number | Page id |
url |
text | Full page URL |
path |
text | Path portion of the URL, without the domain |
seoTitle |
text | SEO title, same as title |
seoDescription |
text | Meta description |
Product fields (each item in allProducts, and currentProduct)
| Field | Type | Description |
|---|---|---|
id |
number | Unique product id. Use it to target one specific product |
name |
text | Internal product name from product settings |
title |
text | Public-facing title. Empty when you haven’t set one, so fall back to name |
price |
number | The amount actually charged, unformatted |
display_price |
text | The price shown to customers when you’ve set one, for example a formatted or promotional price. Empty when unset |
image_url |
text | Product image URL |
is_ob |
boolean | true when the product is configured as an order bump |
is_physical |
boolean | true when the product requires shipping |
is_subscription |
boolean | true when the product bills on a recurring cycle |
billing_amount |
number | The recurring charge, for subscription products |
billing_cycle |
number | How many units between charges, for example 3 |
cycle_type |
number | The unit for billing_cycle: 1 days, 2 weeks, 3 months, 4 years |
billing_recurring_times |
number | How many times it charges. Empty means until cancelled |
has_trial |
boolean | true when the subscription starts with a trial |
trial_billing_amount |
number | Amount charged for the trial period |
trial_billing_cycle |
number | Length of the trial, in trial_cycle_type units |
trial_cycle_type |
number | Unit for the trial length, same mapping as cycle_type |
order_index |
number | The product’s position in the funnel’s product list |
variants |
object | Variant data for products with variants |
options |
array | Variant option sets, for example size and color |
step_id |
number | Id of the step the product belongs to |
funnel_id |
number | Id of the funnel the product belongs to |
created_at |
date | When the product was created |
updated_at |
date | When the product was last modified |
Blog pages
| Variable | Type | Description |
|---|---|---|
blogPost |
object | The post being viewed. Only on post template pages |
allBlogPosts |
array | Every published post on the blog, newest first, up to 200 |
latestBlogPosts |
array | The most recent published posts |
currentCategoryPosts |
array | Posts in the category being viewed. Only on category pages |
allBlogCategories |
array | Every category on the blog |
blogPage |
object | The current blog page’s metadata |
category |
object | The category being viewed. Only on category pages |
Title / title |
text | The page’s SEO title |
seoDescription |
text | The page’s meta description |
blogPost fields (post items in the collections carry the same fields, minus the body)
| Field | Type | Description |
|---|---|---|
name |
text | Post title |
title |
text | Alias of name |
html |
text | The full post body as HTML |
content |
text | Alias of html |
excerpt |
text | Short summary of the post |
author_name |
text | Display name of the author |
author |
text | Alias of author_name |
published_by_author_on |
date | Publication date |
updated_by_author_at |
date | Last edit date. Empty when never edited, which makes it a clean condition for an “Updated” badge |
reading_time_minutes |
number | Estimated reading time |
word_count |
number | Number of words in the post |
thumbnail_url |
text | Featured image URL |
url |
text | Full post URL, including https:// |
seo_title |
text | SEO title, may differ from the post title |
seo_description |
text | Meta description |
categories |
array | Category ids the post belongs to |
category_names |
array | Category names, ready to display |
post_categories |
array | The full category records linked to the post |
blogPage fields
| Field | Type | Description |
|---|---|---|
title |
text | SEO title of the page |
name |
text | Internal page name |
url |
text | Full page URL |
seoDescription |
text | Meta description |
category fields
| Field | Type | Description |
|---|---|---|
name |
text | Category name as displayed |
slug |
text | URL-safe category name |
category_id |
text | Unique category id |
Customer portal pages
| Variable | Type | Description |
|---|---|---|
allProducts |
array | Products available in the portal, for upsells and add-ons |
customerPortalPage |
object | The current portal page’s metadata: title, name, url, seoDescription |
Title / title |
text | The page’s SEO title |
Live subscription data is client-side. See Part 2.
Portal product fields
| Field | Type | Description |
|---|---|---|
product_id |
text | Unique product id |
name |
text | Internal product name |
title |
text | Public-facing title |
description |
text | Product description |
price |
number | The amount charged |
display_price |
text | The price shown to customers when set |
image_url |
text | Product image URL |
is_ob |
boolean | true when configured as an order bump |
product_variant_options |
array | The product’s variants |
Variant fields (each item in product_variant_options)
| Field | Type | Description |
|---|---|---|
id |
text | Unique variant id |
title |
text | Variant name, for example “Large / Blue” |
option1, option2, option3 |
text | The individual option values making up the variant |
price |
number | Variant price |
image_url |
text | Variant image URL |
is_sold_out |
boolean | true when the variant is unavailable |
Filters
Text
| Filter | Description | Example |
|---|---|---|
default |
Value to use when the input is empty | {{ blogPost.excerpt | default: "Read more" }} |
upcase / downcase |
Convert to upper or lower case | {{ category.name | upcase }} |
capitalize |
Capitalize the first letter | {{ category.slug | capitalize }} |
truncate |
Cut to a character count, adding “…” | {{ blogPost.excerpt | truncate: 120 }} |
truncatewords |
Cut to a word count | {{ blogPost.excerpt | truncatewords: 20 }} |
append / prepend |
Add text after or before | {{ product.price | prepend: "$" }} |
replace / replace_first |
Swap text, all occurrences or the first | {{ product.name | replace: "Pack", "Bundle" }} |
remove / remove_first |
Delete text, all occurrences or the first | {{ blogPost.url | remove_first: "https://" }} |
strip / lstrip / rstrip |
Trim whitespace, both ends or one | {{ product.name | strip }} |
strip_html |
Remove all HTML tags | {{ blogPost.html | strip_html | truncate: 200 }} |
strip_newlines |
Remove line breaks | {{ blogPost.excerpt | strip_newlines }} |
newline_to_br |
Convert line breaks to <br> |
{{ blogPost.excerpt | newline_to_br }} |
escape / escape_once |
HTML-escape the value | {{ product.name | escape }} |
url_encode / url_decode |
Make text URL-safe, or decode it | {{ blogPost.name | url_encode }} |
slice |
Take part of a string or array | {{ category.slug | slice: 0, 3 }} |
split |
Break text into an array on a separator | {{ product.title | split: " - " }} |
size |
Length of a string or array | {{ allProducts | size }} |
Numbers
| Filter | Description | Example |
|---|---|---|
plus / minus |
Add or subtract | {{ product.price | plus: 5 }} |
times / divided_by |
Multiply or divide | {{ product.price | times: 2 }} |
round |
Round, optionally to decimals | {{ product.price | times: 0.8 | round: 2 }} |
ceil / floor |
Round up or down to a whole number | {{ product.price | ceil }} |
abs |
Absolute value | {{ product.price | abs }} |
modulo |
Remainder of a division | {{ forloop.index | modulo: 2 }} |
Dates
date formats a date using strftime codes:
{{ blogPost.published_by_author_on | date: "%B %d, %Y" }} March 18, 2026
{{ blogPost.published_by_author_on | date: "%d/%m/%Y" }} 18/03/2026
{{ blogPost.updated_by_author_at | date: "%b %e" }} Mar 18
| Code | Output |
|---|---|
%Y / %y |
Four-digit year / two-digit year |
%B / %b |
Full month name / short month name |
%m |
Month number, zero-padded |
%d / %e |
Day, zero-padded / space-padded |
%A |
Weekday name |
%H:%M |
Hours and minutes |
With no format given, dates render as Wed, Mar 18, 26. On blog and portal pages, a value that isn’t a recognizable date passes through unchanged instead of breaking the page.
Arrays
| Filter | Description | Example |
|---|---|---|
where |
Keep only items whose field equals a value | {{ allProducts | where: "is_ob", true }} |
where_exp |
Keep only items matching an expression | {{ allProducts | where_exp: "p", "p.price > 50" }} |
map |
Extract one field from every item | {{ allProducts | map: "name" | join: ", " }} |
first / last |
The first or last item | {{ allBlogPosts | first }} |
join |
Combine items into one string | {{ blogPost.category_names | join: ", " }} |
sort / sort_natural |
Sort by a field, sort_natural ignores case |
{{ allProducts | sort: "price" }} |
reverse |
Reverse the order | {{ allBlogPosts | reverse }} |
uniq |
Remove duplicate items | {{ blogPost.categories | uniq }} |
compact |
Remove empty items | {{ allProducts | compact }} |
concat |
Join two arrays into one | {{ latestBlogPosts | concat: currentCategoryPosts }} |
slice |
Take a range of items | {{ allBlogPosts | slice: 0, 3 }} |
where and where_exp are Funnelish additions to the standard filter set, and they’re what make product and post lists genuinely programmable.
Debugging
| Filter | Description |
|---|---|
json |
Output the whole value as JSON. The fastest way to see what a page actually holds |
inspect |
Output a readable dump of the value |
type |
Output the value’s data type |
{{ allProducts | json }}
Remove these before publishing.
Tags
| Tag | Description |
|---|---|
if / elsif / else |
Render a block only when a condition is true |
unless |
Render a block when a condition is false |
case / when |
Choose one block from several based on a value |
for |
Loop over an array |
break / continue |
Exit a loop, or skip to the next item |
cycle |
Alternate between values on each pass of a loop |
assign |
Store a value in a variable |
capture |
Store a whole block of output in a variable |
comment |
A note that never renders |
raw |
Output Liquid syntax literally, without evaluating it |
tablerow |
Generate HTML table rows from an array |
Conditions
{% if product.display_price %}
<span class="sale">{{ product.display_price }}</span> <s>${{ product.price }}</s>
{% else %}
${{ product.price }}
{% endif %}
Loops, with forloop exposing index (from 1), index0, first, last and length:
{% for post in allBlogPosts limit: 3 %}
<h3>{{ post.name }}</h3>
<p>{{ post.excerpt | truncate: 100 }}</p>
{% endfor %}
Variables
{% assign discounted = product.price | times: 0.75 | round: 2 %}
Today only ${{ discounted }} instead of ${{ product.price }}
For anything visual, bind a Repeater to the array instead of writing a loop. You get real, styleable elements rather than raw HTML.
Liquid examples
Subscription terms, only on subscription products
{% if product.is_subscription %}
Every {{ product.billing_cycle }} months · ${{ product.billing_amount }}
{% endif %}
Free trial line
{% if product.has_trial %}
Try it for ${{ product.trial_billing_amount }}, then ${{ product.billing_amount }}
{% endif %}
A custom product list with order bumps excluded
{% assign mains = allProducts | where: "is_ob", false %}
{% for p in mains %}
<div>{{ p.title | default: p.name }} — {{ p.display_price | default: p.price | prepend: "$" }}</div>
{% endfor %}
Just the premium tier
{% assign premium = allProducts | where_exp: "p", "p.price > 100" | first %}
{{ premium.title }}
Blog post meta line
By {{ blogPost.author_name }} · {{ blogPost.published_by_author_on | date: "%B %d, %Y" }} · {{ blogPost.reading_time_minutes }} min read
Excerpt with the post body as a fallback
{{ blogPost.excerpt | default: blogPost.html | strip_html | truncate: 160 }}
Part 2: Client-side data
Some data can’t exist when the page is rendered, because it belongs to the individual visitor: the name they’re typing, the product they just selected, their subscription, the UTM tags on the link they clicked. That data lives in the browser and is read with x-attributes on elements.
x-attributes are not Liquid. Writing
{{ customer.first_name }}outputs those characters literally. Usex-textand its siblings instead.
Customer data
$store.session.customer holds every form field the visitor has filled, on this page or an earlier step. Values persist in the browser and are restored as they move through the funnel, which is how an upsell page greets someone by the name they typed at checkout.
| Field | Description |
|---|---|
email |
Email address, captured as soon as they type it when auto-optin is on |
first_name / last_name |
Name parts, as entered |
phone |
Phone number |
address, city, state, zip, country |
Billing address parts |
shipping_address, shipping_city, shipping_state, shipping_zip, shipping_country |
Shipping address parts, when your form collects them separately |
meta.<name> |
Any custom field you collect, keyed by the field’s name |
Custom fields are stored flat. A custom field named
companyis$store.session.customer['meta.company'], notcustomer.meta.company.
Order data
$store.session.order tracks what the visitor is buying, live.
| Field | Type | Description |
|---|---|---|
selected |
array | Ids of the products currently selected on the order form. Updates as they change their choice |
purchased |
array | Ids of the products they actually bought. Populated on thank you and upsell pages |
total |
number | The running order total on the order form, the placed-order total afterwards |
Subscription data (customer portal)
$store.cp holds the signed-in customer’s portal data.
| Field | Type | Description |
|---|---|---|
subscriptions |
array | All of the customer’s subscriptions |
orders |
array | Their order history |
currentSubscription |
object | The subscription currently open |
currentId |
text | Id of the open subscription |
currentSubscription fields
| Field | Type | Description |
|---|---|---|
status |
text | active, trial, paused or past_due |
next_billing |
date | When the next charge is due |
billing_cycle |
number | How many units between charges |
cycle_type |
number | Unit for the cycle: 1 days, 2 weeks, 3 months, 4 years |
gateway_id |
number | Payment gateway handling it: 1 Stripe, 7 PayPal |
items |
array | The products in the subscription |
items_count |
number | How many items it contains |
customer |
object | The subscriber’s details |
credit_card |
object | The payment method on file |
Reading it in custom HTML
| Attribute | Description |
|---|---|
x-text |
Output a value as text |
x-html |
Output a value as HTML |
x-show |
Show the element only when the expression is true |
<span x-text="$store.session.customer.first_name"></span>
<span x-text="$store.session.customer.first_name || 'there'"></span>
<div x-show="$store.session.order.selected.includes('12345')">
Great choice, the 3-pack ships free.
</div>
<span x-text="$store.session.customer['meta.company']"></span>
These update live: as the visitor types their name, anything bound to it re-renders immediately.
Conditions, without writing code
You rarely need to hand-write the above. The builder can show, hide or restyle an element from the same live data using conditions on element states, and it writes the expression for you. Add a state to an element, give it a condition, and the state turns itself on whenever the condition holds.
| Source | Fields | Where |
|---|---|---|
| Order | Selected product, purchased product, order total | Funnel pages |
| URL parameter | Any query parameter, by name | Everywhere |
| UTM tags | utm_source, utm_medium, utm_campaign, utm_term, utm_content |
Everywhere |
| Customer metadata | Any custom field you collect | Everywhere |
| Current subscription | Status, gateway, next billing date, billing cycle, cycle type, items count | Portal pages |
| Subscription | Status, name, next billing date, items count, whether this card is the selected one | Inside a subscriptions list |
| Subscription item | Name, status, quantity | Inside a subscription’s items |
| Customer | Shipping country | Portal pages |
Operators: is, is not, contains, does not contain, includes, does not include, greater than, greater than or equal, less than, less than or equal, is blank, is not blank.
What people build with it:
-
A bonus banner that appears only when a specific product is selected.
-
Different copy for visitors arriving from a particular
utm_source. -
An upgrade CTA shown only when the order total is below a threshold.
-
A “Resume subscription” button that appears only when the status is paused.
Conditions re-evaluate as the data changes, so a banner tied to a product selection appears and disappears as the visitor changes their mind.
Custom JavaScript
For anything further, the page exposes a global funnelish object.
Read the current session
const { customer, order } = funnelish.session()
console.log(customer.email, order.total)
Hook into lifecycle events with funnelish.on(event, handler)
| Event | Fires when |
|---|---|
load |
The page runtime has finished loading |
optin |
An optin form is submitted |
of_purchase |
A purchase completes on the order form |
oto_yes |
An upsell offer is accepted |
oto_no |
An upsell offer is declined |
next_step |
The visitor moves to the next step |
summary_loaded |
The thank you page order summary has loaded |
discount_applied |
A discount code is successfully applied |
app_loaded |
An app finished loading. Receives the app’s name and id |
request_login_otp |
A portal login code is requested |
submit_login |
A portal login is submitted |
funnelish.on('of_purchase', () => {
// fire your own tracking, show a message, whatever you need
})
Raw page data is also on the window: PRODUCTS, FUNNEL, STEP, PAGE, CUSTOMER_PORTAL.
When nothing renders
Server-side, in order of likelihood:
-
Wrong page type.
blogPostdoesn’t exist on a checkout page,allProductsdoesn’t exist on a blog post. Check the tables above. -
Empty value. The field exists but has no content. Add a
default. -
Wrong case or name.
allProducts, notallproducts.Page, notpage. -
Syntax error. A missing
%}orendforfails the whole expression. If a section renders as visible Liquid code, that’s the giveaway.
Drop {{ allProducts | json }} into a paragraph to see exactly what the page holds.
Client-side:
-
You used
{{ }}instead ofx-text. By far the most common mistake. -
The field is still empty. Customer fields fill in as the visitor types, so there’s nothing there on the first step.
-
Wrong key for a custom field. It’s
customer['meta.company'], notcustomer.meta.company.
Open the browser console and run funnelish.session() to see what’s available right now.
Related articles
Written an expression that won’t cooperate? Paste it in Ask the community with your page type, someone will spot it.