Skip to content

Writing a node

Node anatomy, role markers, settings, slots, children strategies, and asset blocks.

Updated View as Markdown

Nodes are the unit of editing in a theme. Every node is one file in nodes/, and the filename is the type. This guide walks through the anatomy, then the decisions you make when designing a node.

Anatomy of a node file

The default theme’s order is: body → {% stylesheet %}{% javascript %}{% schema %}.

1. The role marker (line 1)

@editor:section   {# top-level: "Add section" panel, page or group #}
@editor:any       {# nested: "Add child" panel inside another node #}

The marker is extracted at load time into the schema’s role. hero, navbar, footer, columns, featured_products are sections; text_block, button, navlink, product_title, social_link are content atoms. Sections may contain children (their schema says what they accept); atoms are leaves.

2. The body, template logic against node

{% set align_class = "rr-hero--text-" ~ (node.settings.text_alignment or "center") %}

<div class="rr-hero {{ align_class }}">
  {% if node.settings.heading %}<h1>{{ node.settings.heading }}</h1>{% endif %}
  {{ node.slots.cta }}
</div>

Guidelines from the default theme:

  • Settings are always populated, defaults were merged before render. Guard with {% if %} only when the default is empty/meaningless.
  • Prefer or "fallback" for values that may be unset (node.settings.text_alignment or "center").
  • Escape user-entered text: {{ node.settings.text | render_text | newlines_to_br }} for merchant text; plain {{ }} auto-escapes everything else.

3. The schema, what the editor shows

See Schema blocks for the full field reference. The decisions:

  • tag, pick the semantic element: section for page sections, div for generic containers, footer/header/article/aside where they fit. The renderer wraps your body in this tag with id="rr-node-{id}" and rr-node rr-node--{type} classes.
  • limit, cap instances (hero: 1, newsletter: 2, featured_collections: 3).
  • accepts, which children are allowed. ["@theme"] admits any node (columns, rows, navbar); explicit lists give the editor a curated palette (footer accepts only footer_column + newsletter_column).
  • slots, named buckets when children play different roles (hero’s col and cta slots).

4. Asset blocks

{% stylesheet %}
.rr-hero { … }
{% endstylesheet %}

{% javascript %}
const el = document.getElementById('$$node_id$$');
/* … */
{% endjavascript %}
  • CSS is subset per rendered type: include your own CSS here, and it ships whenever the node renders. Use var(--color-*) tokens, not hardcoded colors.
  • JS is emitted per node instance with $$node_id$$ replaced by the node’s DOM id. Wrap selectors in $$node_id$$ scoping, the default theme does this in announcement_bar, navbar, navmenu, and slideshow.
  • Not every node needs blocks. 24 of the default theme’s 45 nodes have a stylesheet; only 5 have JavaScript.

Section vs simple: what differs

Nothing at runtime, both render through the same recursive path. The role only changes where the editor lets the merchant add the node. The default theme’s split:

  • Sections compose pages: hero, columns, rows, slideshow, rich_text, featured_products, featured_collections, testimonials, newsletter, announcement_bar, navbar, footer, contact_form, catalog_products, collection_products, collections_list, search_results, product_detail, product_recommendations.
  • Simple nodes are atoms: text_block, image, button, button_block, navlink, navmenu, navmenu_item, social_link, testimonial, collection_card, footer_column, newsletter_column, quantity_selector, variant_picker, buy_buttons, all the product_* atoms, product_accordion, product_share, product_metafields, product_gallery, product_badge, product_description, product_title, product_vendor, product_price, product_image, collection_filters (no schema).

Children strategies

A parent node has three ways to place its children. Pick deliberately:

1. render_node(child), inline, current scope

{% for child in node.children %}
  {{ render_node(child) }}
{% endfor %}

Children render now, with the parent’s loop scope visible. This is how featured_products renders its product-card atoms once per product, and how columns/rows lay out arbitrary children.

2. node.children_rendered, pre-rendered, simple

{{ node.children_rendered }}

Children were rendered before the parent’s template ran (default slot only). Simple, but loop scope from the parent isn’t visible to children. Used by navbar (renders the nav twice, desktop + mobile, with one render), footer, navmenu.

3. Manual dispatch, the orchestrator pattern

{% for child in node.children %}
  {% if child.type == 'product_image' %}{% elif child.type == 'product_title' %}{% endif %}
{% endfor %}

The parent decides per child what markup to emit. product_detail (the PDP orchestrator) is the extreme case, 14 accepted child types, each rendered into a specific column with specific wrapper markup; rich_text, testimonials, featured_collections, newsletter, and slideshow dispatch similarly.

Note: featured_collections and slideshow inline their children’s markup rather than calling render_node, the child schema exists for the editor, but the parent owns the output. slideshow’s preset uses a built-in slide child type that has no node file at all.

Slots in practice

Declared slots group children by purpose:

"slots": {
  "col": { "accepts": ["@theme"] },
  "cta": { "accepts": ["button"], "max": 3 }
}
{{ node.slots.col }}
{% if node.slots.cta %}<div class="rr-hero__actions">{{ node.slots.cta }}</div>{% endif %}

Slot HTML is pre-rendered per slot name; children without a slot field land in default (also available as node.children_rendered). The editor’s “Add child” panel shows only the types each slot accepts.

Editor hooks in your markup

The renderer wraps every node, but children you emit inside a loop or deep in your markup lose the wrapper’s data-rr-* attrs. Emit them yourself when it matters:

<div {{ child.attributes }}>…</div>

node.attributes expands to data-rr-node="{id}" data-rr-type="{type}"

  • the preview editor’s selection overlay targets those.

Known default-theme quirks to avoid

  • button_block’s schema has no size setting, but rich_text passes child.settings.size to it, a latent inconsistency.
  • collection_filters has no {% schema %}, developer-managed only.
  • The image_url filter used by navbar/product_gallery/ product_recommendations is not registered in the engine (see Filters).
Navigation

Type to search…

↑↓ navigate↵ selectEsc close