Theme App Extensions
Theme app extensions let you add your app's functionality directly into a merchant's storefront without editing their theme code. With Online Store 2.0, merchants drag and drop your app blocks into their pages using the theme editor -- no code changes, no broken themes during app uninstallation. This is the standard way to integrate app UI into Shopify storefronts.
Online Store 2.0 and App Blocks
Online Store 2.0 introduced a section-based architecture where every page is composed of sections, and sections contain blocks. App blocks are a special type of block that your app registers. Merchants add them through the theme editor just like native Shopify blocks.
There are two types of theme app extensions:
App Blocks
App blocks are visible UI components that merchants place within sections. Examples include product review widgets, size guides, trust badges, and countdown timers.
- Rendered within a section's block list
- Merchant controls position relative to other blocks
- Can be added to any section that supports blocks
- Configured through the theme editor's settings panel
App Embed Blocks
App embed blocks are injected into the page globally, outside the section structure. They are ideal for functionality that appears on every page regardless of layout.
- Rendered at the document level (typically in
<head>or before</body>) - Always active once enabled -- not tied to a specific section
- Common for chat widgets, analytics scripts, floating buttons, and notification bars
- Toggled on/off in the theme editor's app embeds panel
Creating a Theme App Extension
# Generate a theme app extension
shopify app generate extension --template theme_app_extension --name product-reviews
This creates:
extensions/product-reviews/
├── blocks/
│ ├── review-widget.liquid
│ └── review-summary.liquid
├── assets/
│ ├── reviews.css
│ └── reviews.js
├── locales/
│ └── en.default.json
├── snippets/
│ └── star-rating.liquid
└── shopify.extension.toml
Extension Configuration
# extensions/product-reviews/shopify.extension.toml
api_version = "2025-01"
[[extensions]]
type = "theme"
name = "Product Reviews"
handle = "product-reviews"
[[extensions.blocks]]
type = "review-widget"
name = "Review Widget"
target = "section"
[[extensions.blocks]]
type = "review-summary"
name = "Review Summary"
target = "section"
[[extensions.embeds]]
type = "review-popup"
name = "Review Request Popup"
Building App Blocks with Liquid
App blocks are written in Liquid, Shopify's template language. They have access to the same Liquid objects as theme code, plus your app's configuration settings.
Review Widget Block
{% comment %}
blocks/review-widget.liquid
Displays product reviews with star ratings and filtering
{% endcomment %}
{% schema %}
{
"name": "Review Widget",
"target": "section",
"stylesheet": "reviews.css",
"javascript": "reviews.js",
"settings": [
{
"type": "range",
"id": "reviews_per_page",
"min": 3,
"max": 20,
"step": 1,
"default": 5,
"label": "Reviews per page"
},
{
"type": "select",
"id": "default_sort",
"label": "Default sort order",
"options": [
{ "value": "newest", "label": "Newest first" },
{ "value": "highest", "label": "Highest rated" },
{ "value": "lowest", "label": "Lowest rated" },
{ "value": "helpful", "label": "Most helpful" }
],
"default": "newest"
},
{
"type": "checkbox",
"id": "show_photos",
"label": "Show review photos",
"default": true
},
{
"type": "checkbox",
"id": "show_verified_badge",
"label": "Show verified purchase badge",
"default": true
},
{
"type": "color",
"id": "star_color",
"label": "Star color",
"default": "#FFB800"
},
{
"type": "color",
"id": "star_empty_color",
"label": "Empty star color",
"default": "#E0E0E0"
}
]
}
{% endschema %}
<div
class="app-reviews-widget"
data-product-id="{{ product.id }}"
data-reviews-per-page="{{ block.settings.reviews_per_page }}"
data-default-sort="{{ block.settings.default_sort }}"
data-show-photos="{{ block.settings.show_photos }}"
style="
--star-color: {{ block.settings.star_color }};
--star-empty-color: {{ block.settings.star_empty_color }};
"
>
{% comment %} Summary section with aggregate rating {% endcomment %}
<div class="reviews-summary">
<div class="reviews-summary__average">
{% assign avg_rating = product.metafields.reviews.average_rating | default: 0 %}
{% assign review_count = product.metafields.reviews.count | default: 0 %}
<span class="reviews-summary__score">{{ avg_rating | round: 1 }}</span>
{% render 'star-rating', rating: avg_rating %}
<span class="reviews-summary__count">
{{ review_count }} {{ review_count | pluralize: 'review', 'reviews' }}
</span>
</div>
{% if review_count > 0 %}
<div class="reviews-summary__breakdown">
{% for i in (1..5) reversed %}
{% assign star_count = product.metafields.reviews[i | append: '_star_count'] | default: 0 %}
{% assign percentage = star_count | times: 100.0 | divided_by: review_count | round %}
<div class="reviews-breakdown__row">
<span class="reviews-breakdown__label">{{ i }} star</span>
<div class="reviews-breakdown__bar">
<div
class="reviews-breakdown__fill"
style="width: {{ percentage }}%"
></div>
</div>
<span class="reviews-breakdown__count">{{ star_count }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% comment %} Filter and sort controls {% endcomment %}
<div class="reviews-controls">
<div class="reviews-controls__filter">
<label for="reviews-rating-filter">Filter by rating:</label>
<select id="reviews-rating-filter" class="reviews-filter-select">
<option value="all">All ratings</option>
<option value="5">5 stars</option>
<option value="4">4 stars</option>
<option value="3">3 stars</option>
<option value="2">2 stars</option>
<option value="1">1 star</option>
</select>
</div>
</div>
{% comment %} Reviews list -- populated by JavaScript {% endcomment %}
<div class="reviews-list" id="reviews-list-{{ product.id }}">
<div class="reviews-loading">Loading reviews...</div>
</div>
{% comment %} Write a review CTA {% endcomment %}
{% if customer %}
<div class="reviews-write">
<button
class="reviews-write__button"
data-product-id="{{ product.id }}"
data-customer-id="{{ customer.id }}"
>
Write a Review
</button>
</div>
{% else %}
<div class="reviews-write">
<p>
<a href="{{ routes.account_login_url }}">Log in</a> to write a review.
</p>
</div>
{% endif %}
{% if block.settings.show_verified_badge %}
<div class="reviews-badge-info">
<svg class="reviews-verified-icon" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0L10 5.5L16 6L11.5 10L13 16L8 13L3 16L4.5 10L0 6L6 5.5Z" fill="currentColor"/>
</svg>
<span>Verified Purchase reviews are from customers who bought this product.</span>
</div>
{% endif %}
</div>
The {% schema %} block defines settings that appear in the theme editor when a merchant selects your app block. Use descriptive labels and sensible defaults. Provide info text for non-obvious settings. Group related settings together. The merchant experience in the theme editor is a key differentiator for app adoption.
Star Rating Snippet
{% comment %}
snippets/star-rating.liquid
Renders a star rating display
Usage: {% render 'star-rating', rating: 4.5 %}
{% endcomment %}
<div class="star-rating" role="img" aria-label="{{ rating }} out of 5 stars">
{% for i in (1..5) %}
{% if rating >= i %}
<svg class="star-rating__star star-rating__star--full" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 1L12.39 6.97L18.78 7.6L13.89 11.84L15.28 18.08L10 14.85L4.72 18.08L6.11 11.84L1.22 7.6L7.61 6.97L10 1Z" fill="var(--star-color, #FFB800)"/>
</svg>
{% elsif rating > i | minus: 1 %}
<svg class="star-rating__star star-rating__star--half" width="20" height="20" viewBox="0 0 20 20">
<defs>
<linearGradient id="half-{{ i }}">
<stop offset="50%" stop-color="var(--star-color, #FFB800)"/>
<stop offset="50%" stop-color="var(--star-empty-color, #E0E0E0)"/>
</linearGradient>
</defs>
<path d="M10 1L12.39 6.97L18.78 7.6L13.89 11.84L15.28 18.08L10 14.85L4.72 18.08L6.11 11.84L1.22 7.6L7.61 6.97L10 1Z" fill="url(#half-{{ i }})"/>
</svg>
{% else %}
<svg class="star-rating__star star-rating__star--empty" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 1L12.39 6.97L18.78 7.6L13.89 11.84L15.28 18.08L10 14.85L4.72 18.08L6.11 11.84L1.22 7.6L7.61 6.97L10 1Z" fill="var(--star-empty-color, #E0E0E0)"/>
</svg>
{% endif %}
{% endfor %}
</div>
Integrating with Metafields
Metafields are the primary way to store and retrieve app data in theme extensions. Your app writes data to metafields via the Admin API, and your Liquid blocks read them.
Defining Metafield Access
// In your app's shopify.app.toml, declare which metafields your theme extension reads
[access.metafields]
[[access.metafields.read]]
namespace = "reviews"
key = "average_rating"
[[access.metafields.read]]
namespace = "reviews"
key = "count"
[[access.metafields.read]]
namespace = "reviews"
key = "data"
Writing Metafields from Your App
// app/services/reviews.server.ts
export async function updateProductReviewMetafields(
admin: AdminApiContext,
productId: string,
reviews: Review[]
) {
const avgRating =
reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
const starCounts = [1, 2, 3, 4, 5].map(
(star) => reviews.filter((r) => r.rating === star).length
);
await admin.graphql(`
mutation SetReviewMetafields($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
`, {
variables: {
metafields: [
{
ownerId: productId,
namespace: 'reviews',
key: 'average_rating',
type: 'number_decimal',
value: String(avgRating.toFixed(2)),
},
{
ownerId: productId,
namespace: 'reviews',
key: 'count',
type: 'number_integer',
value: String(reviews.length),
},
...starCounts.map((count, index) => ({
ownerId: productId,
namespace: 'reviews',
key: `${index + 1}_star_count`,
type: 'number_integer',
value: String(count),
})),
],
},
});
}
Reading Metafields in Liquid
{% comment %} Access metafields in your app block {% endcomment %}
{% assign avg = product.metafields.reviews.average_rating.value %}
{% assign count = product.metafields.reviews.count.value %}
{% if count > 0 %}
<div class="review-badge">
{% render 'star-rating', rating: avg %}
<span>({{ count }})</span>
</div>
{% endif %}
Always use your app's reserved namespace for metafields. If another app uses the same namespace and key, data collisions will occur. Shopify reserves certain namespaces (like global). Use a namespace that clearly identifies your app, such as my_app_reviews.
Best Practices
Performance
- Minimize JavaScript -- Load JS only when the block is visible. Use
IntersectionObserverto lazy-load. - Use CSS custom properties -- Let merchants customize colors without loading additional stylesheets.
- Avoid blocking the main thread -- All API calls should be asynchronous. Never use synchronous XHR.
- Cache aggressively -- Use
localStoragefor review data that does not change frequently.
Merchant Experience
- Provide meaningful defaults -- Every setting should have a sensible default so the block looks good immediately.
- Include preview data -- Use Liquid's
request.design_modeto show sample data in the theme editor. - Handle empty states -- Show a friendly message when there are no reviews, not a blank space.
- Document settings -- Use the
infoproperty in schema settings to explain what each option does.
{% if request.design_mode %}
{% comment %} Show sample data in the theme editor {% endcomment %}
<div class="reviews-preview">
{% render 'star-rating', rating: 4.5 %}
<p>Preview: 127 reviews will appear here</p>
</div>
{% endif %}
Clean Uninstallation
When a merchant uninstalls your app, your theme extension blocks are automatically removed from their theme. This is a core benefit of theme app extensions over legacy Script Tag or Asset API approaches. However, you should still clean up any metafields your app created, as they persist after uninstallation. Handle the APP_UNINSTALLED webhook to remove orphaned metafields.
App Embed Block Example
{% comment %}
blocks/review-popup.liquid (embed block)
Shows a post-purchase review request popup
{% endcomment %}
{% schema %}
{
"name": "Review Request Popup",
"target": "body",
"stylesheet": "review-popup.css",
"javascript": "review-popup.js",
"settings": [
{
"type": "range",
"id": "delay_days",
"min": 1,
"max": 14,
"step": 1,
"default": 7,
"label": "Days after purchase to show popup",
"info": "How many days after a customer's purchase before prompting for a review"
},
{
"type": "text",
"id": "heading_text",
"label": "Popup heading",
"default": "How are you enjoying your purchase?"
},
{
"type": "checkbox",
"id": "show_on_mobile",
"label": "Show on mobile devices",
"default": false,
"info": "Popups can be disruptive on small screens"
}
]
}
{% endschema %}
<div
id="review-request-popup"
class="review-popup review-popup--hidden"
data-delay-days="{{ block.settings.delay_days }}"
data-show-mobile="{{ block.settings.show_on_mobile }}"
role="dialog"
aria-label="{{ block.settings.heading_text }}"
aria-hidden="true"
>
<div class="review-popup__overlay"></div>
<div class="review-popup__content">
<button class="review-popup__close" aria-label="Close">×</button>
<h2 class="review-popup__heading">{{ block.settings.heading_text }}</h2>
<div class="review-popup__body" id="review-popup-form">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
Continue to POS UI Extensions to learn how to extend Shopify's Point of Sale application with custom UI components for in-store experiences.