All Skills

Use this skill when the user asks about "theme extension", "app blocks", "Liquid templates", "app embed", "storefront rendering", "app proxy", or any Theme App Extension development work. Provides patterns for Liquid-first storefront rendering with minimal JavaScript.

d
$npx skills add ducnm-mimhus/Avada-Simple-Sales-Pop --skill theme-extension

Theme App Extension Development

Overview

Theme App Extensions use Shopify's native Liquid rendering for optimal storefront performance. Content is server-rendered before reaching the customer's browser.

When to Use

ApproachUse For
Theme ExtensionStatic/semi-static content, SEO-critical content, product displays
ScripttagHighly interactive widgets, real-time updates, complex UI

Directory Structure

extensions/theme-extension/
├── blocks/                    # App blocks (merchant can position)
│   ├── feature-block.liquid   # Main feature block
│   └── badge.liquid           # Small badge/snippet
├── snippets/                  # Reusable Liquid snippets
│   └── component.liquid       # Shared components
├── assets/                    # JS/CSS files
│   ├── app-embed.js           # Minimal JS (forms, interactions)
│   └── styles.css             # Scoped styles
└── locales/
    └── en.default.json        # Translations

App Block Pattern

Block Schema

{% schema %}
{
  "name": "Feature Name",
  "target": "section",
  "enabled_by_default": false,
  "settings": [
    {
      "type": "range",
      "id": "items_per_page",
      "label": "Items per page",
      "min": 3,
      "max": 20,
      "default": 10
    },
    {
      "type": "color",
      "id": "primary_color",
      "label": "Primary color",
      "default": "#000000"
    }
  ]
}
{% endschema %}

{% comment %} Block content {% endcomment %}
<div class="app-feature" data-resource-id="{{ product.id }}">
  {% render 'app-component', resource: product, settings: block.settings %}
</div>

{% stylesheet %}
  .app-feature { /* scoped styles */ }
{% endstylesheet %}

{% javascript %}
  // Minimal JS only when needed
{% endjavascript %}

Block Targets

TargetUse For
sectionProduct page, collection page content
bodyGlobal elements (popups, floating buttons)

App Embed (Global Script)

For loading global CSS and minimal JS across all pages:

{% schema %}
{
  "name": "App Embed",
  "target": "body",
  "enabled_by_default": true,
  "settings": []
}
{% endschema %}

{% comment %} Load styles globally {% endcomment %}
{{ 'styles.css' | asset_url | stylesheet_tag }}

{% comment %} Minimal JS - forms, interactions only {% endcomment %}
<script src="{{ 'app-embed.js' | asset_url }}" defer></script>

{% comment %} Pass data to JS {% endcomment %}
<script>
  window.APP_CONFIG = {
    shopDomain: '{{ shop.permanent_domain }}',
    customerId: '{{ customer.id | default: "" }}',
    locale: '{{ request.locale.iso_code }}'
  };
</script>

App Proxy Integration

Configure in shopify.app.toml

[app_proxy]
url = "https://<firebase-url>/clientApi"
subpath = "app-name"
subpath_prefix = "apps"

Fetch Data via App Proxy

{% comment %}
  App proxy URL: /apps/app-name/endpoint
  Shopify adds shop param automatically
{% endcomment %}

{% assign api_url = shop.url | append: '/apps/app-name/data/' | append: resource.id %}

Backend Handler

// clientApi controller - receives 'shop' query param from app proxy
export async function getData(ctx) {
  const shopDomain = ctx.query.shop;
  const {id} = ctx.params;

  const shop = await shopRepository.getShopByShopifyDomain(shopDomain);
  if (!shop) {
    ctx.body = {success: false, error: 'Shop not found'};
    return;
  }

  const data = await service.getData(shop.id, id);
  ctx.body = data;
}

Async Data Fetching with Loading States

When blocks need dynamic data from app proxy, use loading states for good UX.

HTML Structure

{% comment %}
  Generic async widget pattern
  - Unique IDs using resource ID (product, collection, page, etc.)
  - Data attributes for JS configuration
  - Loading/content/error states
{% endcomment %}

{% assign widget_id = product.id | default: collection.id | default: 'global' %}

<div class="app-widget"
     data-resource-id="{{ widget_id }}"
     data-resource-type="{{ resource_type | default: 'product' }}"
     data-shop="{{ shop.permanent_domain }}"
     id="app-widget-{{ widget_id }}">

  <div class="app-widget__loading" id="app-loading-{{ widget_id }}">
    <span class="app-widget__spinner"></span>
    {{ 'app.loading' | t | default: 'Loading...' }}
  </div>

  <div class="app-widget__content" id="app-content-{{ widget_id }}" style="display: none;">
    {%- comment -%} Content populated by JS {%- endcomment -%}
  </div>

  <div class="app-widget__error" id="app-error-{{ widget_id }}" style="display: none;">
    {{ 'app.error' | t | default: 'Unable to load content' }}
  </div>
</div>

JavaScript Pattern

(function() {
  // Configuration
  var PROXY_PATH = '/apps/proxy';  // Your app proxy path
  var ENDPOINT = '/data';          // Your endpoint

  // Get elements
  var containers = document.querySelectorAll('.app-widget');

  containers.forEach(function(container) {
    var resourceId = container.dataset.resourceId;
    var resourceType = container.dataset.resourceType;
    var shop = container.dataset.shop;

    var loadingEl = document.getElementById('app-loading-' + resourceId);
    var contentEl = document.getElementById('app-content-' + resourceId);
    var errorEl = document.getElementById('app-error-' + resourceId);

    async function fetchData() {
      try {
        var params = new URLSearchParams({
          resource_id: resourceId,
          resource_type: resourceType,
          shop: shop
        });

        var response = await fetch(PROXY_PATH + ENDPOINT + '?' + params);
        var result = await response.json();

        if (!result.success) {
          throw new Error(result.error || 'Request failed');
        }

        if (!result.data) {
          container.dataset.hidden = 'true';
          return;
        }

        renderContent(result.data, result.settings);
        loadingEl.style.display = 'none';
        contentEl.style.display = 'block';
      } catch (error) {
        console.error('Widget error:', error);
        loadingEl.style.display = 'none';
        errorEl.style.display = 'block';
      }
    }

    function renderContent(data, settings) {
      // Build your HTML from data
      contentEl.innerHTML = buildHTML(data, settings);
    }

    // Initialize on DOM ready
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', fetchData);
    } else {
      fetchData();
    }
  });
})();

CSS Utilities

/* Loading spinner */
.app-widget__spinner {
  width: 16px;
  height: 16px;
  border: 2px solid #e5e5e5;
  border-top-color: var(--app-accent-color, #008060);
  border-radius: 50%;
  animation: app-spin 0.8s linear infinite;
}

@keyframes app-spin {
  to { transform: rotate(360deg); }
}

/* Hide widget when no data */
.app-widget[data-hidden="true"] {
  display: none;
}

/* Loading state */
.app-widget__loading {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 16px;
  color: #666;
}

/* Error state */
.app-widget__error {
  padding: 16px;
  color: #c00;
  text-align: center;
}

Key Points

AspectRecommendation
Unique IDsUse resource ID (product, collection, etc.) for uniqueness
Loading stateAlways show spinner while fetching
Error handlingShow user-friendly error, hide widget on failure
Empty stateUse data-hidden="true" to hide when no data
TranslationsUse Liquid t filter for user-facing text

Liquid-First Rendering

Server-Side Data Display

{% comment %} Data from metafields or app proxy {% endcomment %}
{% assign items = product.metafields.app_namespace.items.value %}

{% if items.size > 0 %}
  <div class="items-list">
    {% for item in items %}
      {% render 'item-card', item: item %}
    {% endfor %}
  </div>
{% else %}
  <p class="no-items">{{ 'app.no_items' | t }}</p>
{% endif %}

Reusable Snippets

{% comment %} snippets/rating-display.liquid {% endcomment %}
{% comment %}
  @param value {number} - Value to display
  @param max {number} - Maximum value
  @param size {string} - 'small' | 'medium' | 'large'
{% endcomment %}

<div class="rating-display rating-display--{{ size | default: 'medium' }}">
  {% for i in (1..max) %}
    {% if i <= value %}
      <span class="rating-unit rating-unit--filled"></span>
    {% else %}
      <span class="rating-unit rating-unit--empty"></span>
    {% endif %}
  {% endfor %}
</div>

Minimal JavaScript Pattern

When JS is Needed

Use JS ForKeep in Liquid
Form submissionStatic content display
File uploadBadges and indicators
Pagination/load moreProduct lists
Real-time validationSEO content

Minimal JS Structure

// assets/app-embed.js

(function() {
  'use strict';

  const config = window.APP_CONFIG || {};

  // Form handling
  function initForms() {
    document.querySelectorAll('[data-app-form]').forEach(form => {
      form.addEventListener('submit', handleSubmit);
    });
  }

  async function handleSubmit(e) {
    e.preventDefault();
    const form = e.target;
    const data = new FormData(form);

    try {
      const response = await fetch(form.action, {
        method: 'POST',
        body: JSON.stringify(Object.fromEntries(data)),
        headers: {'Content-Type': 'application/json'}
      });

      const result = await response.json();
      if (result.success) {
        showMessage(form, 'success', result.data.message);
      } else {
        showMessage(form, 'error', result.error);
      }
    } catch (err) {
      showMessage(form, 'error', 'Something went wrong');
    }
  }

  // Initialize when DOM ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initForms);
  } else {
    initForms();
  }
})();

Translations

Locale File Structure

{
  "app": {
    "title": "Feature Title",
    "submit": "Submit",
    "success": "Submitted successfully",
    "error": "Something went wrong",
    "no_items": "No items yet"
  }
}

Using Translations

{% comment %} In Liquid {% endcomment %}
{{ 'app.title' | t }}

{% comment %} With variables {% endcomment %}
{{ 'app.count' | t: count: items.size }}

Performance Guidelines

Do's

PracticeWhy
Render in LiquidServer-side = faster
Use snippetsReusable, cacheable
Defer JS loadingNon-blocking
Scope CSSAvoid conflicts

Don'ts

AvoidWhy
Heavy JS frameworksBundle size
Inline stylesCache miss
Blocking scriptsSlow page load
Fetch on page loadDelays rendering

Checklist

Block Development

- Schema defines all settings
- Uses snippets for reusable parts
- Translations for all text
- Scoped CSS (no global pollution)
- Minimal/no inline JS

App Proxy Integration

- shopify.app.toml configured
- Backend validates shop parameter
- Returns JSON for JS consumption
- HTML rendering for Liquid consumption

Performance

- JS deferred or async
- CSS in stylesheet (not inline)
- Images lazy loaded
- No external dependencies
- Bundle < 20KB total