테마 앱 확장
테마 앱 확장을 사용하면 판매자의 테마 코드를 편집하지 않고 앱 기능을 판매자의 스토어프론트에 직접 추가할 수 있습니다. 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:
앱 블록
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 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
테마 앱 확장 생성
# Generate a theme app extension
shopify app generate extension --template theme_app_extension --name product-reviews
다음이 생성됩니다:
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"
Liquid로 앱 블록 구축
앱 블록은 Shopify의 템플릿 언어인 Liquid로 작성됩니다. They have access to the same Liquid objects as theme code, plus your app's configuration settings.
리뷰 위젯 블록
{% 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>
메타필드와 통합
메타필드는 테마 확장에서 앱 데이터를 저장하고 검색하는 주요 방법입니다. Your app writes data to metafields via the Admin API, and your Liquid blocks read them.
메타필드 접근 정의
// 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.
모범 사례
성능
- 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.
판매자 경험
- 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 %}
깔끔한 제거
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">×</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.