Skip to main content

Theme Block Extensions

Theme blocks are Liquid-based UI components that merchants can add to their storefront themes. They appear in the theme editor alongside native blocks, allowing merchants to position and configure them visually.

How Theme Blocks Work

  1. Your app installs block files (.aqua template + .schema.json schema)
  2. Blocks appear in the merchant’s theme editor under “App Blocks”
  3. Merchants drag blocks into sections and configure settings
  4. LaunchMyStore renders your block using the Liquid template engine

Block File Structure

Each theme block requires two files:
extensions/{domainSlug}/{appHandle}/blocks/
├── product-reviews.aqua          # Liquid template
└── product-reviews.schema.json   # Settings schema

Creating a Theme Block

1. Write the Liquid Template

Theme blocks use .aqua files with standard Liquid syntax:
{% comment %} product-reviews.aqua {% endcomment %}

<div class="app-reviews-widget" data-product-id="{{ product.id }}">
  {% if block.settings.show_title %}
    <h3 class="reviews-title">{{ block.settings.title }}</h3>
  {% endif %}
  
  <div class="reviews-summary">
    {% render 'review-stars', rating: product.metafields.reviews.average %}
    <span class="review-count">
      {{ product.metafields.reviews.count }} reviews
    </span>
  </div>
  
  <div class="reviews-list" id="reviews-{{ product.id }}">
    {% for review in product.metafields.reviews.items limit: block.settings.limit %}
      <div class="review-item">
        <div class="review-header">
          <strong>{{ review.author }}</strong>
          {% render 'review-stars', rating: review.rating %}
        </div>
        <p class="review-body">{{ review.body }}</p>
      </div>
    {% endfor %}
  </div>
  
  {% if block.settings.enable_pagination %}
    <button class="load-more" data-page="2">Load More Reviews</button>
  {% endif %}
</div>

<style>
  .app-reviews-widget {
    padding: {{ block.settings.padding }}px;
    background: {{ block.settings.background_color }};
  }
  .reviews-title {
    color: {{ block.settings.title_color }};
    font-size: {{ block.settings.title_size }}px;
  }
</style>

{# Per-app CSS/JS is shipped via the manifest's stylesheetUrl/scriptUrl
   fields — there is no `extension_asset_url` filter. The host injects
   them automatically when the block renders. See "Per-app CSS/JS" below. #}

2. Define the Schema

The schema controls what settings appear in the theme editor:
{
  "name": "Product Reviews",
  "target": "product",
  "settings": [
    {
      "type": "header",
      "content": "Content"
    },
    {
      "type": "text",
      "id": "title",
      "label": "Section Title",
      "default": "Customer Reviews"
    },
    {
      "type": "checkbox",
      "id": "show_title",
      "label": "Show Title",
      "default": true
    },
    {
      "type": "range",
      "id": "limit",
      "label": "Reviews to Show",
      "min": 3,
      "max": 20,
      "step": 1,
      "default": 5
    },
    {
      "type": "checkbox",
      "id": "enable_pagination",
      "label": "Enable Load More",
      "default": true
    },
    {
      "type": "header",
      "content": "Styling"
    },
    {
      "type": "color",
      "id": "background_color",
      "label": "Background Color",
      "default": "#ffffff"
    },
    {
      "type": "color",
      "id": "title_color",
      "label": "Title Color",
      "default": "#333333"
    },
    {
      "type": "range",
      "id": "title_size",
      "label": "Title Size",
      "min": 14,
      "max": 36,
      "step": 2,
      "default": 24,
      "unit": "px"
    },
    {
      "type": "range",
      "id": "padding",
      "label": "Padding",
      "min": 0,
      "max": 60,
      "step": 5,
      "default": 20,
      "unit": "px"
    },
    {
      "type": "header",
      "content": "Advanced"
    },
    {
      "type": "checkbox",
      "id": "enable_lazy_load",
      "label": "Lazy Load Reviews",
      "default": false,
      "info": "Load reviews via JavaScript for better performance"
    }
  ]
}

Top-level Schema Fields

The schema JSON accepts the following top-level keys:
FieldTypeDescription
namestring (required)Display name in the theme editor block picker.
targetstring | string[] (required)Page-type slot where the block can be inserted. See Available Targets.
tagstringHTML wrapper element for the block (default section).
classstringClass name applied to the wrapper element. Useful for shared per-app CSS hooks.
settingsobject[]Block-level settings shown in the theme editor. See Setting Types.
blocksobject[]Nested block templates the merchant can add inside this block — same type / name / settings shape as a section’s blocks array.
max_blocksintegerHard cap on how many nested blocks a merchant can add (default 50, configurable via MAX_BLOCKS_PER_SECTION).
presetsobject[]Pre-configured combinations of settings + nested blocks shown as quick-add options in the picker. Each preset has { name, settings?, blocks? }.
color_schemestringDefault color-scheme handle to apply (e.g. "scheme-1"). Honoured by themes that wire schemes through their t:section.color_scheme plumbing.
{
  "name": "Trust Badge Row",
  "tag": "div",
  "class": "plinth-trust-row",
  "target": ["product", "cart"],
  "settings": [
    { "type": "range", "id": "columns", "label": "Columns", "min": 2, "max": 6, "default": 4 }
  ],
  "blocks": [
    {
      "type": "badge",
      "name": "Trust Badge",
      "settings": [
        { "type": "text", "id": "icon", "label": "Icon", "default": "truck" },
        { "type": "text", "id": "label", "label": "Label", "default": "Free shipping" }
      ]
    }
  ],
  "max_blocks": 6,
  "presets": [
    { "name": "Default", "blocks": [{ "type": "badge" }, { "type": "badge" }, { "type": "badge" }] }
  ]
}

Available Targets

The target field determines which page types your block can be added to:
TargetDescriptionAvailable Objects
indexHomepageshop, collections, articles
productProduct pagesproduct, shop, collection
collectionCollection pagescollection, products, shop
cartCart pagecart, shop
articleBlog articlesarticle, blog, shop
pageCustom pagespage, shop
blogBlog listingblog, articles, shop
searchSearch resultssearch, results, shop
You can target multiple page types by specifying an array: "target": ["product", "collection"]

Setting Types

Basic Inputs

{
  "type": "text",
  "id": "heading",
  "label": "Heading Text",
  "default": "Welcome",
  "placeholder": "Enter heading..."
}
{
  "type": "textarea",
  "id": "description",
  "label": "Description",
  "default": "",
  "placeholder": "Enter description..."
}
{
  "type": "number",
  "id": "columns",
  "label": "Number of Columns",
  "default": 3
}

Selection Inputs

{
  "type": "select",
  "id": "layout",
  "label": "Layout Style",
  "options": [
    { "value": "grid", "label": "Grid" },
    { "value": "list", "label": "List" },
    { "value": "carousel", "label": "Carousel" }
  ],
  "default": "grid"
}
{
  "type": "radio",
  "id": "alignment",
  "label": "Text Alignment",
  "options": [
    { "value": "left", "label": "Left" },
    { "value": "center", "label": "Center" },
    { "value": "right", "label": "Right" }
  ],
  "default": "left"
}

Range & Toggle

{
  "type": "range",
  "id": "opacity",
  "label": "Opacity",
  "min": 0,
  "max": 100,
  "step": 5,
  "default": 100,
  "unit": "%"
}
{
  "type": "checkbox",
  "id": "show_badge",
  "label": "Show Badge",
  "default": true,
  "info": "Display a badge on featured items"
}

Visual Pickers

{
  "type": "color",
  "id": "accent_color",
  "label": "Accent Color",
  "default": "#007bff"
}
{
  "type": "color_background",
  "id": "background",
  "label": "Background",
  "default": "linear-gradient(180deg, #ffffff 0%, #f5f5f5 100%)"
}
{
  "type": "image_picker",
  "id": "banner_image",
  "label": "Banner Image"
}

Resource Pickers

{
  "type": "product",
  "id": "featured_product",
  "label": "Featured Product"
}
{
  "type": "collection",
  "id": "source_collection",
  "label": "Source Collection"
}
{
  "type": "url",
  "id": "link",
  "label": "Link URL"
}

Installing Theme Blocks

Install blocks when your app is installed on a store:
const response = await fetch('https://store.launchmystore.io/api/apps/install-extensions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${accessToken}`
  },
  body: JSON.stringify({
    domainSlug: 'merchant-store',
    appHandle: 'my-reviews-app',
    extensions: {
      blocks: [
        {
          handle: 'product-reviews',
          template: `{% comment %}Your Liquid template{% endcomment %}...`,
          schema: {
            name: 'Product Reviews',
            target: 'product',
            settings: [/* ... */]
          }
        }
      ],
      snippets: [
        {
          handle: 'review-stars',
          template: `{% for i in (1..5) %}...{% endfor %}`
        }
      ],
      assets: [
        {
          filename: 'reviews.js',
          url: 'https://my-app.example.com/dist/reviews.js'
        },
        {
          filename: 'reviews.css',
          url: 'https://my-app.example.com/dist/reviews.css'
        }
      ]
    }
  })
});

Per-app CSS/JS

There is no extension_asset_url filter. Apps ship CSS/JS in one of two ways:

1. Inline <style> / <script> in the .aqua template

For small, dynamic CSS that depends on block.settings.*, inline a <style> tag inside the template (as shown in the example above). This is the recommended approach for block-scoped styles.

2. Bundled assets (per-merchant disk copy)

Files in extensions.assets[] are downloaded and stored under extensions/{domainSlug}/{appHandle}/assets/. They’re publicly served at the same path, so reference them as absolute URLs:
<link rel="stylesheet" href="/extensions/{{ __domainSlug }}/my-reviews-app/assets/reviews.css">
__domainSlug is the merchant’s store identifier and is the same path segment the host uses when copying your bundled assets onto disk.

3. App embeds (page-wide scripts)

For scripts that should load on every page (analytics, chat widgets, review widgets that scan the page), use an app embed instead of a block. See App Embeds — embeds support scriptSrc, inlineHtml, and stylesheetUrl and are injected by the host into the rendered HTML.
Block manifests accept stylesheetUrl and scriptUrl fields, but the host does not currently inject them when the block renders. If your block needs external CSS/JS, declare it as an app embed (option 3) or inline it in the .aqua template (option 1).

Theme assets (different from extension assets)

For files inside the merchant’s theme (the theme’s assets/ folder), use the standard Liquid filters:
  • {{ 'foo.css' | asset_url }} — theme asset CDN URL
  • {{ 'foo.png' | asset_img_url }} — theme image asset
  • {{ 'foo.svg' | inline_asset_content }} — inline SVG content
  • {{ 'foo.pdf' | file_url }} — theme file CDN URL

Snippets

Create reusable components as snippets:
{% comment %} snippets/review-stars.aqua {% endcomment %}

<div class="star-rating" aria-label="{{ rating }} out of 5 stars">
  {% assign full_stars = rating | floor %}
  {% assign half_star = rating | modulo: 1 | round %}
  {% assign empty_stars = 5 | minus: full_stars | minus: half_star %}
  
  {% for i in (1..full_stars) %}
    <span class="star star--full"></span>
  {% endfor %}
  
  {% if half_star == 1 %}
    <span class="star star--half"></span>
  {% endif %}
  
  {% for i in (1..empty_stars) %}
    <span class="star star--empty"></span>
  {% endfor %}
</div>
Then render the snippet from your block:
{% render 'review-stars', rating: 4.5 %}

Accessing Data

Block Settings

Access settings via block.settings:
{{ block.settings.title }}
{{ block.settings.show_title }}
{{ block.settings.background_color }}

Global Objects

Standard Liquid theme global objects are available:
{{ shop.name }}
{{ product.title }}
{{ customer.email }}
{{ cart.item_count }}

Metafields

Access your app’s metafields:
{{ product.metafields.my_app.rating }}
{{ shop.metafields.my_app.settings | json }}

Best Practices

Avoid complex logic in Liquid. Pre-compute values in your backend and store them in metafields.
Use proper heading levels, ARIA labels, and semantic elements for accessibility.
Prefix class names with your app handle to avoid style conflicts with the theme.
Use defer or dynamic imports for JavaScript to avoid blocking page render.
Respect the merchant’s theme colors and fonts where possible. Use CSS custom properties.
Test your blocks with multiple themes to ensure compatibility with different layouts.
A complete example showing a featured products carousel:
<div class="app-featured-products" data-app="{{ block.settings.app_id }}">
  <div class="featured-header">
    {% if block.settings.show_title %}
      <h2 class="featured-title">{{ block.settings.title }}</h2>
    {% endif %}
    {% if block.settings.show_subtitle %}
      <p class="featured-subtitle">{{ block.settings.subtitle }}</p>
    {% endif %}
  </div>
  
  <div class="featured-grid" style="--columns: {{ block.settings.columns }}">
    {% assign collection = collections[block.settings.collection] %}
    {% for product in collection.products limit: block.settings.limit %}
      <div class="featured-product">
        <a href="{{ product.url }}">
          <img 
            src="{{ product.featured_image | image_url: width: 400 }}" 
            alt="{{ product.title }}"
            loading="lazy"
          >
          <h3>{{ product.title }}</h3>
          <p class="price">{{ product.price | money }}</p>
        </a>
        <button class="add-to-cart" data-variant="{{ product.first_available_variant.id }}">
          Add to Cart
        </button>
      </div>
    {% endfor %}
  </div>
</div>

<style>
  .app-featured-products {
    padding: {{ block.settings.padding }}px 0;
  }
  .featured-grid {
    display: grid;
    grid-template-columns: repeat(var(--columns), 1fr);
    gap: 20px;
  }
  @media (max-width: 768px) {
    .featured-grid {
      grid-template-columns: repeat(2, 1fr);
    }
  }
</style>