Skip to content

Templating

Themes render with Liquid (the same language Shopify themes use, via the keepsuit/liquid PHP implementation). This page is the full contract: the data a view can read, the filters and tags available, and how settings and translations reach a view.

The data model: the storefront GraphQL schema

Everything a view can read (unit types, properties, the cart, site settings, blog posts…) is defined by the storefront GraphQL schema, versioned (currently 2026-01) at graphql/storefront/2026-01/ in the platform. The schema is the object catalog: there's no separate list to keep in sync.

A theme never writes GraphQL by hand for the built-in pages; the platform resolves each page's data and hands it to your view as plain Liquid variables (objects and arrays, keys snake_cased from the schema's camelCase fields, so unitType.baseRate arrives as unit_type.base_rate). The full set of queries and mutations:

OperationReturnsUsed for
settingsSettings (siteName, brandName, currencyCode, bookingMode, pricingVisibility, siteContactAddress, siteContactEmail, siteContactPhone)Site-wide info. Injected as general_settings on every page.
themeSettingsThemeSettings (settingsJson)Your theme's own settings, declared in config/settings_schema.json. Injected as settings on every page.
menu[MenuItem] (label, url, openInNewTab, translationKey)Navigation, from config/menu.json. Injected as menu.
siteSite (policies, pages: each { url, title })Footer links to policy pages and CMS pages. Injected as site.
locales[Locale] (code, name, nameLocal, image, isDefault)Published locales, for a language switcher. Injected as locales (each entry also gets a computed url).
countries[Country] (isoCode, name, phone, currency)Country <select> at checkout.
unitTypes / unitType(slug)[UnitType] / UnitTypeAccommodation listing and detail page. Injected as unit_types on the home page and the listing page. A block can show its own subset, see the accommodations picker.
properties[Property]Multi-property hosts. Not injected by any built-in page, available if your own operation needs it.
homePagePage (title, blocks: [ThemeBlock], isHome)The home page's section list. See Sections and blocks.
page(slug) / policy(slug)Page (slug, title, content, isHome, blocks: [ThemeBlock]) / PolicyCMS pages (also block-composed) and legal/policy pages.
reservedPage(key)Page (title, blocks: [ThemeBlock])Section list for a reserved page (blog, blog-post, or contact). See Sections and blocks.
blogPosts / blogPost(slug)[BlogPost] / BlogPostBlog listing and detail.
cartCartResult (lines, totalUnits, totalAmount)Cart page and checkout sidebar.
cartAdd / cartUpdate / cartRemoveCartAddResult / CartAddResult / CartRemoveResult (each with userErrors)Add-to-cart widget, cart line quantity, remove line.
checkoutRequestSubmitCheckoutRequestSubmitResult (mode, bookingId, statusUrl, redirectUrl, userErrors)Submits the checkout form (guest details, optional B2B billing fields).
bookingRequestCreateBookingRequestCreateResult (inquiryId, userErrors)Review-mode booking requests (non-instant bookingMode).
paymentStartPaymentStartResult (redirectUrl, userErrors)Starts (or retries) a payment with a chosen payment app.
reviewSubmitReviewSubmitResult (status, userErrors)The post-stay review form.
contactSubmit / newsletterSubscribe…Result (success, userErrors)Contact form and newsletter widget.
bookingPaymentStatus(token)BookingPaymentStatusThe post-checkout payment-status page. See booking-status.liquid.
reviewRequestState(token)ReviewRequestStateThe post-stay review page. See review.liquid.
themeRuntimeThemeRuntime (slug, version)Diagnostics.

unit_type.kind is standard for an ordinary room type or entire_property for a listing that books the whole property; a listing page can use it to label or filter entire-property listings separately from rooms, but every other field and the booking flow itself work the same for both.

Every mutation result carries userErrors: [{ field, message }]. Check it before treating a mutation as successful, even when the HTTP call itself succeeded.

How pages actually call this

The built-in pages (home, accommodations, cart, checkout, …) are rendered server-side: the platform runs the query for you and injects the result as Liquid variables, so most of a theme never touches GraphQL directly. Anything interactive on a rendered page (add to cart, submit checkout, submit a review, subscribe to the newsletter) is client-side: your theme's own JavaScript calls the public, per-store GraphQL endpoint, POST /api/{version}/graphql.json (same-origin, session-cookie authenticated), and re-renders the page based on the response. See Sections and blocks below for the server-rendered half, and Persisted operations for how that endpoint treats the documents it executes.

Filters

Two filters are platform-specific; everything else is keepsuit's standard Liquid filter set.

t: translate a key

liquid
{{ 'nav.home' | t }}
{{ 'cart.total_units' | t: count: cart.total_units }}
{{ item.translation_key | t: fallback: item.label }}

Looks up a dot-notation key in your theme's locales/<locale>.json (see Locales and routing). Named arguments:

  • count: picks the one / other form when the catalog entry for that key is an object ({ "one": "…", "other": "…" }) instead of a plain string, and is interpolated into {{ count }} inside the chosen string.
  • fallback: returned as-is when the key resolves to nothing (missing from both the active and default-locale catalogs). Without it, a missing key falls back to printing the key itself.
  • Any other named argument is interpolated into a matching {{ name }} placeholder in the translated string (HTML-escaped).

json: inline a value as JSON

liquid
<script>window.StorefrontLocale = {{ locale | json }};</script>

Compact JSON encoding with no HTML-escaping: the counterpart to Liquid's default auto-escaping {{ }} output, for the one case (embedding a value inside a <script> block) where you want the raw JSON representation instead of an escaped string. Prefer this over string-concatenating values into inline scripts.

Standard filters actually in use

Nothing beyond keepsuit's stock filter library is registered. The ones the reference theme uses: date (strftime-style: {{ 'now' | date: '%Y' }}, {{ booking_payment.check_in | date: '%d %b %Y' }}), default ({{ entry.name_local | default: entry.code }}), escape, size (as a property: unit_types.size, cart.lines.size), join, first, last, map, where, truncate, upcase/downcase/capitalize. There is no money filter and no asset filter. Money values and image/asset URLs already arrive display-ready strings from the schema; see Assets for how asset paths work.

Tags

if / unless / elsif / else, for (with limit:, offset:, and loop.first / loop.last / loop.index), assign, and include are what you'll use in practice. include 'snippet-name' (the snippet name is quoted; there's no with keyword) shares the calling view's full variable scope: a snippet like header/footer reads settings, menu, locale, etc. without them being passed explicitly:

liquid
{% include 'header' %}
{% include 'unit-card', unit_type: unit_type %}

This is a platform customization. Standard Liquid (and keepsuit) dropped shared-scope include in favor of isolated-scope render, but themes here use include, matching the Shopify convention themes are modeled on. There is no section or schema tag; a section's configurable data comes from config/blocks.json and arrives as the block_settings variable (see Sections and blocks), not an inline schema block.

== / != are blank-aware: x != blank is true whenever x is missing, false, an empty/whitespace string, or an empty array, the idiomatic guard for "this optional field has content":

liquid
{% if unit_type.description != blank %}
  <div class="rich-content">{{ unit_type.description }}</div>
{% endif %}

Beyond these, keepsuit's other standard tags (capture, case/when, cycle, increment/decrement, raw) are available if you need them; the reference theme just doesn't.

settings, general_settings, and block_settings

Three different things read like "settings". Keep them straight:

VariableSourceScope
settingsYour theme's config/settings_schema.json, edited by the host on the theme settings pageWhole theme, every page
general_settingsThe team's own system settings (site name, currency, booking mode; not theme-specific)Whole theme, every page
block_settingsOne block's fields, from config/blocks.json, edited per-instance in the page/section customizerOnly inside that block's sections/*.liquid view
liquid
{% if settings.base_color %}
  <style>:root { --brand-color: {{ settings.base_color }}; }</style>
{% endif %}

{% if general_settings.booking_mode != 'disabled' %}
  <a href="/accommodations">{{ 'nav.book_now' | t }}</a>
{% endif %}

{% if general_settings.pricing_visibility == 'always' %}
  <span class="price">{{ unit_type.price_display }}</span>
{% endif %}

Two general_settings fields gate booking and pricing UI, and a theme that shows either must honour both:

  • booking_mode is 'disabled', 'request', 'request_availability', or 'instant'. When it is 'disabled' the team takes no bookings, so hide every booking form, add-to-cart control, and "book now" call to action.
  • pricing_visibility is 'always', 'never', or 'logged_in'. Show prices only when it is 'always'. The storefront has no signed-in guest, so treat 'logged_in' the same as 'never' and keep prices hidden.

Render unit_type.price_display (the currency-formatted nightly rate) rather than the raw unit_type.base_rate number, so the team's currency and formatting are applied for you.

See Theme settings for the settings_schema.json format (the same field-type vocabulary is used by blocks.json).

Sections and blocks

The home page, CMS pages (page.liquid), and the reserved pages (below) are composed of blocks, the customizer's equivalent of Shopify sections. Three pieces work together:

  • config/blocks.json declares the available block types: an id (the Liquid view name under sections/, e.g. hero maps to sections/hero.liquid), a label, and a fields schema (same field types as settings_schema.json: text, image_picker, repeater, grid, …, plus unit_type_picker, which only blocks can use).
  • config/pages.json seeds each page's initial block list on install: one entry per page (title; is_home: true for the home page, slug for a CMS page, or key for a reserved page; blocks, each { type, visible, data } where type matches a blocks.json id). Hosts reorder, add, remove, and edit these in the page customizer afterward; pages.json is only the starting point, and an existing page (matched by is_home, slug, or key) is left untouched on a later reinstall or theme switch, so a host's edits survive.
  • At render time, homePage (for the home page), page(slug) (for a CMS page), or reservedPage(key) (for a reserved page) returns the page's current blocks ({ type, visible, settingsJson }); the platform renders each visible block's sections/{type}.liquid with that block's parsed settings as block_settings, and joins the output into sections_html:
liquid
{# views/index.liquid #}
{{ sections_html }}

{# views/page.liquid: fall back to plain page content when a page has no blocks #}
{% if sections_html != '' %}
  {{ sections_html }}
{% else %}
  <div class="rich-content">{{ page.content }}</div>
{% endif %}

A sections/*.liquid file only ever receives block_settings for its own fields, plus the same page-wide globals (settings, general_settings, menu, …) every view gets.

Reserved pages: blog, blog-post, contact

Beyond the home page and freeform CMS pages, three built-in pages are also section-customizable: the blog listing, a blog post, and the contact page. Declare them in config/pages.json with a key instead of slug or is_home:

json
[
  { "title": "Blog", "key": "blog", "blocks": [] },
  { "title": "Blog post", "key": "blog-post", "blocks": [] },
  { "title": "Contact", "key": "contact", "blocks": [] }
]

key must be one of blog, blog-post, or contact; any other value fails publish validation. A reserved entry can't also carry slug or is_home: combining either with key is a validation error. blocks is optional and defaults to [] if omitted, so hosts start with an empty section list they can build on in the customizer.

Give views/blog.liquid, views/blog-post.liquid, and views/contact.liquid a output, the same as index.liquid, so a host's sections actually render, typically after the view's own hardcoded content (the post list, the individual post, the contact form):

liquid
{# views/contact.liquid #}
<section class="contact">
  {# ...your contact form... #}
</section>
{{ sections_html }}

Without that hook a host can still add and arrange blocks for these pages in the customizer, but the storefront never shows them.

blog-post is one shared section list for the resource type, not one per post: the same blocks render underneath every blog post, the way a Shopify theme's single product template applies to every product. Per-post content (title, body, cover image) still comes from the post itself, not from blocks.

The platform fetches a reserved page's blocks with reservedPage(key), the same way it fetches the home page's with homePage. As with any built-in read, you can override the default document by shipping your own graphql/reserved_page.graphql; see Persisted operations.

Letting a host choose which accommodations a block shows

unit_type_picker is a block field type that hands your section unit types, not ids. Declare it in blocks.json:

json
{
  "id": "featured_units",
  "label": "theme_blocks.featured_units.label",
  "fields": [
    {
      "type": "unit_type_picker",
      "id": "unit_types",
      "multiple": true,
      "label": "theme_blocks.featured_units.fields.unit_types.label",
      "helper_text": "theme_blocks.featured_units.fields.unit_types.helper_text"
    }
  ]
}

and loop the field straight from block_settings:

liquid
{# sections/featured_units.liquid #}
{% for unit_type in block_settings.unit_types %}
  <a href="/accommodations/{{ unit_type.slug }}">{{ unit_type.name }}</a>
{% endfor %}

The host gets a searchable multi-select of their bookable unit types. Leaving it empty means every active unit type, so a fresh install shows the host's whole inventory before they configure anything; treat the picker as a way to narrow that, not as something they must fill in. When they do pick, the block gets only those, in the order they picked them. Each entry carries the same fields as the unitTypes read (name, slug, images, price_display, occ_adults, facilities, …), so an accommodation card snippet works unchanged in both places.

Your section never receives ids, so there is nothing to look up and no reason to guard against an empty list caused by a host who has not chosen yet.

Locales and routing

The default locale serves at the bare path (/, /accommodations); every other published locale is prefixed (/bg/, /bg/accommodations). locale and default_locale tell you which is active; locales is the full list (each entry carries the URL of the current page in that locale, for a language switcher); canonical_url is the current page's canonical URL in the active locale:

liquid
<link rel="canonical" href="{{ canonical_url }}">
{% for entry in locales %}
  <link rel="alternate" hreflang="{{ entry.code }}" href="{{ entry.url }}">
{% endfor %}

Storefront copy comes from locales/<code>.json via the t filter, not from literal strings in your views; see t above. Ship at minimum locales/en.json; the platform warns (doesn't block) at publish time if it's missing. A key missing from the active locale's catalog falls back to the default locale's, then to the key itself (or a fallback: argument, if you gave one).

Don't confuse this with lang/<code>.admin.json, an optional, separate catalog for translating your settings_schema.json / blocks.json field labels and helper_text shown to the host on the settings and customizer pages. It has nothing to do with what a guest sees on the storefront.

Persisted operations

Beyond what the platform injects automatically, a theme can ship its own GraphQL operations as .graphql files under graphql/ (e.g. graphql/unit_types.graphql, containing a query named UnitTypes) to override a built-in read with different fields, or to back a mutation your own JavaScript calls. At publish, every graphql/*.graphql file is validated against the live schema (named operations only, no duplicate names across files, query depth and complexity limits enforced, introspection always rejected), and the result is content-addressed and persisted against that theme version.

The public endpoint (POST /api/{version}/graphql.json) reflects that: a live store only ever executes a persisted document, referenced by its content hash (documentId), never an arbitrary query string from the client. While you're iterating (a stayblox theme dev session or the in-browser customizer), the endpoint also accepts a raw document string directly, so you can develop against the real schema before anything is published. A theme you don't customize a given operation for still works: the platform's own default document for that operation serves the request.

App data in themes

The t/json filters and the schema above cover the storefront's own data. Metafields set by an installed app (guest-visible booking or property data) are a separate, opt-in surface. See Metafields in the apps documentation for how an app declares and writes them, and how a theme reads the ones marked "visibility": "guest".

Required views

Two views render pages the platform controls the routing for, not just content you compose. See Theme structure → required views for where they must live.

booking-status.liquid (required)

The post-checkout payment-status page. Context:

VariableTypeDescription
booking_paymentobject|nullnull when the token doesn't resolve to a booking; render a "not found" state. Otherwise: booking_number, payment_status, check_in, check_out, amount_due (null when nothing is owed), currency_code, payment_apps ([{ id, name }]).
page_pay_tokenstring|nullThe token, echoed back only when booking_payment resolved (never reflect an unresolved/garbage URL token into the page). Needed to retry a payment via paymentStart.

Three states to handle, on booking_payment.payment_status:

StateConditionShow
Paid'paid' or 'overpaid'A confirmation message.
Payment dueamount_due is setThe amount due, and a retry action per entry in payment_apps (calls paymentStart with page_pay_token and the chosen app's id).
Processingneither of the aboveA "your payment is being processed" message.

review.liquid (required)

The post-stay review page, reached via a tokenized link (/review/{token}) emailed a few days after checkout. There's no nav entry to it. Context: review (state, property_name, guest_first_name, check_in, check_out) and review_token.

review.state is one of three values your view must handle:

StateMeaningShow
formNo review exists yet for this stayThe submission form (rating 1-5, optional title, body) posting to reviewSubmit with token: review_token.
already_submittedA review for this stay already existsA friendly "already submitted" message.
expiredThe link's validity window passedAn "expired link" message.

A successful reviewSubmit result (status) is what your form's JavaScript uses to swap in a thank-you message client-side; there's no separate server-rendered "thanks" state to branch on.

review_token is guest-controlled input reflected back into the page (a hidden form field). Always let Liquid's default auto-escaping handle it; never mark it "safe" or otherwise bypass escaping.

© Stayblox — Developer Platform