Skip to content

Building a theme from scratch

The full boot sequence, skeleton, manifest, layout, first node, and testing.

Updated View as Markdown

This guide builds a minimal-but-real theme step by step, following themes/default’s conventions. By the end you’ll have a theme that loads, renders, and is editable in the theme editor.

1. Scaffold the skeleton

cargo run -p ringroad-renderer -- init --name "My Theme" --output themes/my-theme

This creates theme.json, layout/theme.jinja, templates/index.json, and empty nodes/, snippets/, assets/ directories. Alternatively, copy themes/default/ and gut it, many theme authors find editing a working theme faster than starting cold.

2. Write theme.json

The manifest. Start with the bare minimum, then add global settings:

{
  "name": "My Theme",
  "version": "1.0.0",
  "settings": [
    {
      "type": "color_palette",
      "id": "colors",
      "label": "Colors",
      "default": {
        "primary": "#0f0f0f",
        "primary_content": "#ffffff",
        "base_100": "#fafaf9",
        "base_200": "#f2f0ed",
        "base_content": "#171717"
      }
    },
    {
      "type": "header",
      "content": "Typography"
    },
    {
      "type": "select",
      "id": "heading_font",
      "label": "Heading font",
      "options": [
        { "value": "inter", "label": "Inter" },
        { "value": "system", "label": "System font" }
      ],
      "default": "inter"
    }
  ],
  "presets": {
    "index": {
      "order": ["hero-1"],
      "sections": {
        "hero-1": { "type": "hero", "settings": {}, "children": {}, "child_order": [] }
      }
    }
  },
  "section_groups": {
    "@root:header-group": {
      "name": "Header",
      "max_sections": 3,
      "preset": { "order": [], "sections": {} }
    },
    "@root:footer-group": {
      "name": "Footer",
      "max_sections": 3,
      "preset": { "order": [], "sections": {} }
    }
  }
}

Notes:

  • presets must cover the page keys your store uses (index, product, collection, …). Empty presets (order: []) are fine, the page just renders no nodes until the merchant adds sections.
  • A color_palette with fewer than 2 colors fails validation, the minimum is 2, max 20.
  • Settings you reference as {{ settings.* }} in templates must exist here (or be missing and render empty under Lenient).

3. Write the layout shell

layout/theme.jinja is the document every page renders inside. Model it on themes/default/layout/theme.jinja:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{{ shop.name }}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="/_assets/main.css">

  {# design tokens from global settings #}
  <style>
    :root {
      --color-primary: {{ settings.colors.primary }};
      --color-base-100: {{ settings.colors.base_100 }};
      --color-base-content: {{ settings.colors.base_content }};
      --container-max: {{ settings.container_width | default(1280) }}px;
    }
  </style>

  {{ settings.html_head | safe }}
  {{ page_styles | safe }}
  <script src="/_assets/main.js" defer></script>
</head>
<body>
  {{ header_html | safe }}
  <main id="main-content">{% block main %}{{ page_content | safe }}{% endblock %}</main>
  {{ footer_html | safe }}
  {{ page_scripts | safe }}
</body>
</html>

The shell must emit page_styles and page_scripts and slot header_html / page_content / footer_html, that’s how node CSS/JS and the section groups reach the page.

4. Write your first node

nodes/hero.jinja, a section with one setting:

@editor:section
<section class="rr-hero" style="min-height: {{ node.settings.height }}px">
  <h1>{{ node.settings.heading }}</h1>
  {% if node.slots.cta %}<div class="rr-hero__actions">{{ node.slots.cta }}</div>{% endif %}
</section>

{% stylesheet %}
.rr-hero { background: var(--color-base-200); padding: 3rem; text-align: center; }
{% endstylesheet %}

{% schema %}
{
  "name": "Hero",
  "tag": "section",
  "limit": 1,
  "settings": [
    { "type": "text", "id": "heading", "label": "Heading", "default": "Welcome" },
    { "type": "range", "id": "height", "label": "Height", "min": 200, "max": 800, "default": 400, "step": 50, "unit": "px" }
  ],
  "slots": {
    "cta": { "accepts": ["button"], "max": 3 }
  },
  "presets": [
    {
      "name": "Default",
      "type": "hero",
      "settings": {},
      "children": [
        { "type": "button", "slot": "cta", "settings": { "label": "Shop now", "url": "/products" } }
      ]
    }
  ]
}
{% endschema %}

Then add the button content node (nodes/button.jinja) the preset references:

@editor:any
{% if node.settings.label %}
<a class="rr-hero__btn" href="{{ node.settings.url or '#' }}">{{ node.settings.label }}</a>
{% endif %}

{% schema %}
{
  "name": "Button",
  "role": "simple",
  "settings": [
    { "type": "text", "id": "label", "label": "Label", "default": "Shop now" },
    { "type": "url", "id": "url", "label": "Link" }
  ]
}
{% endschema %}

5. Add page templates and snippets

  • templates/index.jinja (and every other page key you preset) can be the 102-byte stub, real content comes from the node tree:

    {% extends "my-theme/layout/theme.jinja" %}
    {% block main %}{{ page_content | safe }}{% endblock %}
  • Bespoke pages (cart, checkout, 404, account) override {% block main %} with real markup, see Templates and layout.

  • Extract repeated markup into snippets/ macros early, buttons, inputs, icons, section headings. themes/default/snippets/ui/ is the pattern to copy.

6. Serve and iterate

cargo run -p ringroad-renderer -- serve --path themes --port 7502
# GET http://localhost:7502/ → the preset index page
# GET http://localhost:7502/default/index → explicit slug/page

The server watches themes/ and rebuilds the engine on every change (200 ms debounce). Edit a node, refresh, see it, no API or database needed.

7. Check the load rules

  • Every node type referenced by a preset must exist in nodes/ (missing types are skipped with a warning, not an error).
  • Every {% schema %} must parse as valid JSON and pass setting validation, otherwise engine startup fails and tells you which theme, node, and setting.
  • {% stylesheet %} / {% javascript %} in layout/ files land under __layout__ and always render; in node files they’re subset by rendered types.
  • The default theme smoke test (tests/default_theme_smoke.rs) renders themes/default end to end, a good template for testing your own theme’s loadability.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close