Skip to main content

Theme App エクステンション

Theme App エクステンションを使用すると、マーチャントのテーマコードを編集せずに、アプリの機能をマーチャントのストアフロントに直接追加できます。Online Store 2.0 では、マーチャントがテーマエディターを使用してアプリブロックをページにドラッグ&ドロップできます -- コード変更なし、アプリアンインストール時にテーマが壊れることもありません。これが Shopify ストアフロントにアプリ UI を統合する標準的な方法です。

Online Store 2.0 とアプリブロック

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:

アプリブロック

アプリブロックは、マーチャントがセクション内に配置する可視的な UI コンポーネントです。商品レビューウィジェット、サイズガイド、信頼バッジ、カウントダウンタイマーなどがその例です。

  • 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

アプリ埋め込みブロック

アプリ埋め込みブロックは、セクション構造の外部でページにグローバルに挿入されます。レイアウトに関係なくすべてのページに表示される機能に最適です。

  • 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

Theme App エクステンションの作成

# 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

エクステンション設定

# 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

アプリブロックは Shopify のテンプレート言語である Liquid で記述されます。テーマコードと同じ Liquid オブジェクトに加え、アプリの設定にもアクセスできます。

レビューウィジェットブロック

{% 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.

星評価スニペット

{% 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>

メタフィールドとの統合

メタフィールドは、テーマエクステンションでアプリデータを保存・取得する主要な方法です。アプリは Admin API を通じてメタフィールドにデータを書き込み、Liquid ブロックがそれを読み取ります。

メタフィールドアクセスの定義

// 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"

アプリからのメタフィールド書き込み

// 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),
})),
],
},
});
}

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.

ベストプラクティス

パフォーマンス

  1. Minimize JavaScript -- Load JS only when the block is visible. Use IntersectionObserver to lazy-load.
  2. Use CSS custom properties -- Let merchants customize colors without loading additional stylesheets.
  3. Avoid blocking the main thread -- All API calls should be asynchronous. Never use synchronous XHR.
  4. Cache aggressively -- Use localStorage for review data that does not change frequently.

マーチャント体験

  1. Provide meaningful defaults -- Every setting should have a sensible default so the block looks good immediately.
  2. Include preview data -- Use Liquid's request.design_mode to show sample data in the theme editor.
  3. Handle empty states -- Show a friendly message when there are no reviews, not a blank space.
  4. Document settings -- Use the info property 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 %}

クリーンなアンインストール

クリーンアンインストール要件

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.

アプリ埋め込みブロックの例

{% 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">&times;</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.