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-themeThis 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:
presetsmust 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_palettewith 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/pageThe 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 %}inlayout/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) rendersthemes/defaultend to end, a good template for testing your own theme’s loadability.