Build a content-driven storefront by synchronizing Shopify products into Craft CMS.
Important
Version 7.x of Shopify for Craft uses a new app-based authorization system. You must follow the upgrade instructions to get new credentials.
- 📦 Installation: Set up the plugin and get connected to Shopify.
- 🗃️ Working with Products: Learn what kind of data is available and how to access it.
- 📑 Templating: Tips and tricks for using products in Twig.
- 🍃 Upgrading: Take advantage of new features and performance improvements.
- 🔭 Advanced Features: Go further with your integration.
Shopify requires Craft CMS 4.15.0+ or 5.0.0+.
To install the plugin, visit the Plugin Store from your Craft project, or follow these instructions.
-
Navigate to your Craft project in a new terminal:
cd /path/to/project -
Require the package with Composer:
composer require craftcms/shopify -w
-
In the Control Panel, go to Settings → Plugins and click the “Install” button for Shopify, or run:
php craft plugin/install shopify
The plugin works with Shopify’s Dev Dashboard app system, and is split into two primary parts: creating an app and performing authorization.
To install an app into a store, one of these statements must describe your account’s relationship with it:
- You are the owner of the store;
- You have been added as a collaborator on the store, with the App developer role (see screenshot, below);
- You are working with a dev store or client transfer store belonging to your Partner organization;
Caution
The new OAuth-based API connection requires that apps are created from an “organization” that has access to the Partner Dashboard. Standalone stores (like the one created when you sign up for a Shopify account) belong to their own organization.
- If you are working with a store or account that has never accessed a Partner Dashboard, you must create a Partner profile before proceeding.
- When working from an account that has access to multiple organizations, it is generally safest to access the new Dev Dashboard via the Partner Dashboard you want the app associated with.
- Navigate to your Dev Dashboard:
- From a store, open the account context menu (upper-right corner) and select Dev Dashboard;
- From the Partner Dashboard, open the account context menu (upper-right corner) and select Dev Dashboard;
- In the Dev Dashboard, press Create app.
- In the first screen, pick an App name that identifies the integration, like Craft CMS.
- Press Create, then fill out the following fields to create your first “version”:
- App URL: Retrieve the Shopify App Auth URL value from the plugin’s setting screen in the Craft control panel. (This will always be your project’s URL, followed by the cpTrigger, then the action
shopify/auth:https://my-project.com/admin/shopify/auth.) - Embed app in Shopify admin: Make sure this is unchecked, as the plugin does not support embedded apps.
- Webhooks API Version: Choose
2026-01, and add the same string to your project’s.envfile:SHOPIFY_WEBHOOK_VERSION="2026-01" - Access → Scopes: The following scopes are required for the plugin to function correctly:
read_inventoryread_product_listingsread_products- Shopify requires these to be in a comma-separated list:
read_inventory,read_product_listings,read_products - Do not enable the Use legacy install flow as it can result in mismatched scopes during installation.
- App URL: Retrieve the Shopify App Auth URL value from the plugin’s setting screen in the Craft control panel. (This will always be your project’s URL, followed by the cpTrigger, then the action
- Press Release to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number.
- Switch to the Settings screen of the new app, and copy the credentials into your
.envfile:SHOPIFY_CLIENT_ID="..." # Client ID SHOPIFY_CLIENT_SECRET="..." # Secret
Next, you’ll configure the app’s distribution scheme.
- From the new app’s Home screen in the Dev Dashboard, follow the Select distribution method link, within the Distribution widget.
- The Partner Dashboard will open, with your app selected. Choose Custom distribution, press Select, then confirm in the dialog box.
- Locate your store’s hostname (see screenshot, below), and paste it into the Store domain field, then press Generate link.
- Once you choose a hostname, the app is permanently locked to that store. If you do not provide the correct hostname at this stage, you’ll need to delete the app and start over.
- If you want to use the same connection across multiple related stores, check Allow multi-store install for one Plus organization.
- Take this opportunity to add the hostname to your
.envfile:
SHOPIFY_HOSTNAME="my-store-name.myshopify.com" - Return to the Distribution screen and press Copy link.
You should now have a total of four SHOPIFY_* variables in your .env file:
# 1. Webhook API Version
# This is tied to your app’s release, and should not change (except potentially during a future plugin upgrade).
SHOPIFY_WEBHOOK_VERSION="2026-01"
# 2. Client ID
# This can be found in your Shopify app’s Settings screen.
SHOPIFY_CLIENT_ID="..."
# 3. Secret
# This can be found in your Shopify app’s Settings screen.
SHOPIFY_CLIENT_SECRET="..."
# 4. Hostname
# Found in your store’s settings screen. Include only the domain (no leading `https://`)
SHOPIFY_HOSTNAME="my-store-name.myshopify.com"In the Craft control panel, navigate to Shopify → Settings to configure the plugin:
- API Version:
$SHOPIFY_WEBHOOK_VERSION - Client ID:
$SHOPIFY_CLIENT_ID - Client Secret Key:
$SHOPIFY_CLIENT_SECRET - Host Name:
$SHOPIFY_HOSTNAME
Use these literal strings in the corresponding fields.
As you type the $-prefixed value into an input, Craft will suggest matching variables.
Press Save to commit the settings to project config.
Tip
You may see a warning below the read-only Shopify App Auth URL field. This is expected, until you’ve completed the OAuth flow!
In this step, we’ll perform the authorization code grant or OAuth flow, during which Craft and Shopify negotiate a long-lived access token.
Tip
Whoever installs the app must be able to access to the store and the Craft project from the same browser. Shopify does not need to directly contact the Craft, so you may do this from your local development machine!
- Visit the installation URL you copied from the Distribution screen in the Partner Dashboard. You must be logged in to a Shopify account with access to the target store (but it does not need to be the same account that created the app).
- Select the store in Shopify’s context picker.
- On the Install app screen within the store’s admin, review the permissions and press Install.
[!WARNING] If you do not see a blue banner confirming This app is exclusive to your store, do not proceed! A banner saying This app can’t be installed on this store (or landing on a generic Shopify error page) usually means that the hostname is not valid for the distribution.
- You will be redirected to the Craft control panel “auth” URL you used when creating the Shopify app. (If you were not already logged in, Craft will ask for your username and password; your user must have the Access Shopify permission or be an administrator to complete the authorization flow.)
- Press Authorize in the dialog.
- Craft and Shopify will perform the OAuth handshake, and you should land on a confirmation screen in the Craft control panel saying Your Shopify app has been successfully authorized.
🎊 Congratulations! Your Craft project can now communicate with the Shopify API. Let’s take it for a spin by importing your store’s products.
A new Webhooks tab will appear in the Shopify section of the control panel once you’ve completed the authorization flow.
Click Create webhooks on the Webhooks screen to add the required webhooks to Shopify. The plugin will use your newly-issued access token to perform this operation, so this also serves as an initial communication test.
Warning
You must add webhooks for every environment you deploy the plugin to; webhooks are tied to the specific, registered URL. Be aware that Shopify will continue to attempt delivery to your development environment’s subscriptions, which may impact the statistics you see in the Dev Dashboard. See Cleanup below for help culling unused webhook subscriptions.
Development environments are not typically exposed to the public internet, which means Shopify won’t be able to deliver webhooks.
To test synchronization in development, we recommend using ngrok to create a tunnel to your local environment.
DDEV makes this simple, with the ddev share command.
Tip
Use the SHOPIFY_PUBLIC_DEV_URL environment variable to override your project’s base URL when creating webhooks; this allows you to continue using your regular DDEV site URL for control panel and front-end access, rather than overriding the entire project or site’s base URL.
This setting may not work if you have set a custom cpBaseUrl!
Each time you open an ngrok tunnel, you get a new public URL, and Shopify will be unable to deliver webhooks.
This means that you may accumulate broken subscriptions over the course of development.
In the control panel, we only display the webhooks relevant to the current environment—or, more accurately, those with a uri matching the resolved webhook URL (which can be influenced by the SHOPIFY_PUBLIC_DEV_URL variable).
You can delete individual webhooks from the control panel, or by using the CLI GraphQL playground…
php craft shopify/api/query 'mutation deleteWebhook {
webhookSubscriptionDelete(id: "gid://shopify/WebhookSubscription/123456789") {
userErrors {
field
message
}
deletedWebhookSubscriptionId
}
}'…substituting a known subscription GID.
Discover orphaned subscriptions using the webhookSubscriptions() query.
This release (7.x) is primarily concerned with Shopify API compatability, but the new authentication mechanism means that you’ll need to re-establish the connection to Shopify using the authentication scheme described above.
Due to significant shifts in Shopify’s developer ecosystem, many of the front-end cart management techniques we have recommended (like the JS Buy SDK and Buy Button JS) are no longer viable.
Tip
We strongly recommend reviewing this same section on the 6.x branch, as there were a number of breaking changes and deprecations during the upgrade from 5.x.
After the upgrade, you must delete and re-create webhooks for each environment. Webhooks are registered and delivered with a specific version, and a mismatch will result in errors.
Your “legacy custom app” can be left as-is or deleted, once all your environments have been migrated to the Dev Dashboard connection. While this plugin has no need for those credentials, confirm with the store owner that no other external services depend on them!
At the beginning of 2026, Shopify overhauled how “apps” are created, moving them to the new Dev Dashboard.
You should be able to create a new app, and install it using the new OAuth mechanism, without disruption to product synchronization.
Shopify has eliminated sales channels for custom apps, and therefore the publishedOnCurrentPublication field is no longer available in Product queries.
This means that there is no official way to “publish” products to the Craft integration, but we cover some alternatives in the sales channel emulation section.
The product element editor has received a major overhaul. You can now choose exactly where Shopify data is placed, within the field layout.
Shopify has retired many of its pre-built client-side frameworks, in favor of directly communicating with the generic Storefront GraphQL API. You will need to revise how you query and mutate data, if your front-end currently depends on the JS Buy SDK or Buy Button JS.
Products from your Shopify store are represented in Craft as product elements, and can be found by going to Shopify → Products in the control panel.
Once connected to Shopify, you can perform an initial synchronization of all products, from the control panel (via Utilities → Shopify Sync) or the command line:
php craft shopify/sync/productsThis adds a bulk operation to the plugin’s internal queue. Once Shopify has gathered the data, it will issue a webhook to your project, and the plugin will download and process the payload.
Going forward, your products are automatically kept in sync via webhooks. You can view a history of synchronization operations by visiting the Shopify Sync utility.
Warning
We do our best to capture native Shopify resources that are attached to a product (like variants, media, and options), but cannot dynamically discover relationships with other content via Metafields, or data from third-party apps.
Additional fields can be captured by listening events in a custom module.
In addition to the standard element attributes like id, title, and status, each Shopify product element contains direct accessors for these canonical Shopify Product attributes:
| Attribute | Description | Type |
|---|---|---|
shopifyId |
The integer product ID from Shopify. | Integer |
shopifyGid |
The unique resource identifier (“GID”) from Shopify. This should always be the shopifyId, prepended with gid://shopify/Product/. |
String |
shopifyStatus |
The status of the product in Shopify. Values can be active, draft, or archived. |
String |
handle |
The product’s “URL handle” in Shopify, equivalent to a “slug” in Craft. For existing products, this is visible under the Search engine listing section of the edit screen. | String |
productType |
The product type of the product in your Shopify store. | String |
descriptionHtml |
Product description. Output with the |raw Twig filter—but only if the content is trusted. This was previously called bodyHtml. |
String |
tags |
Tags associated with the product in Shopify. | Array |
templateSuffix |
Liquid template suffix used for the product page in Shopify. | String |
vendor |
Vendor of the product. | String |
data |
The raw API response data from Shopify. (See below) | Array |
metaFields |
Metafields associated with the product. | Array |
images |
Images (or “Media”) attached to the product in Shopify. The complete MediaImage objects are stored in Craft. | Array |
options |
ProductOption objects, as configured in Shopify. Each option has a name, position, and an array of in-use values. |
Array |
defaultVariant (and cheapestVariant) |
The first known (or cheapest) variant belonging to the product. This is one of the few ancillary resources that we make available as a model (craft\shopify\models\Variant). |
Variant |
createdAt |
When the product was created in your Shopify store. (This will almost always be different from the element’s native dateCreated property.) |
DateTime |
publishedAt |
When the product was published in your Shopify store. | DateTime |
updatedAt |
When the product was last updated in your Shopify store. (This will almost always be different from the element’s native dateUpdated property.) |
DateTime |
All of these properties are available when working with a product element in your templates.
Yii and Twig also allow you to access some values via magic getters—any method beginning with get (like product.getDefaultVariant()) can also be treated like a property (product.defaultVariant).
Important
See the Shopify documentation on the product resource for more information about what kinds of values to expect from these properties. The nature of GraphQL (and API versioning) means that we may not be capturing 100% of the available data. To select additional fields, you can intercept the event emitted just before a product GraphQL query is sent.
A complete copy of the requested Shopify API data used to populate a Product element is available under its data property. Wherever possible, we have used Shopify’s native property names—but by virtue of fetching products via GraphQL, there may be differences between the structure of this object and the API documentation, especially as it relates to nested objects. Use the following methods to access related or nested data!
The product element has a few methods you might find useful in your templates.
Returns an array of variants belonging to the product. Variants are not elements (just regular models), but you can use the same dot notation to access their properties:
{% set variants = product.getVariants() %}
<select name="variantId">
{% for variant in variants %}
<option value="{{ variant.id }}">{{ variant.title }}</option>
{% endfor %}
</select>You can eager-load variants alongside products using the product query’s .withVariants() method.
Shortcut for getting the first/default variant belonging to the product.
{% set products = craft.shopifyProducts
.withVariants()
.all() %}
<ul>
{% for product in products %}
{% set defaultVariant = product.getDefaultVariant() %}
<li>
<a href="{{ product.url }}">{{ product.title }}</a>
<span>{{ defaultVariant.price|currency }}</span>
</li>
{% endfor %}
</ul>Shortcut for getting the lowest-priced variant belonging to the product.
{% set cheapestVariant = product.getCheapestVariant() %}
Starting at {{ cheapestVariant.price|currency }}!Note that this does not factor in contextual pricing.
{# Get a link to the product’s page on Shopify: #}
<a href="{{ product.getShopifyUrl() }}">View on our store</a>
{# Link to a product with a specific variant pre-selected: #}
<a href="{{ product.getShopifyUrl({ variant: variant.id }) }}">Buy now</a>This has limited utility if you are displaying products on-site (rather than linking back to a Shopify storefront). To get the URL of a product within your Craft project, use product.url.
For administrators, you can even link directly to the Shopify admin:
{# Assuming you’ve created a custom group for Shopify admin: #}
{% if currentUser and currentUser.isInGroup('clerks') %}
<a href="{{ product.getShopifyEditUrl() }}">Edit product on Shopify</a>
{% endif %}Products synchronized from Shopify have a dedicated field layout, which means they support Craft’s full array of content tools. In addition, you may place these read-only native fields anywhere in the layout to customize your authoring experience:
- Variants: A static table with variants’ names, SKUs, and prices.
- Options: A list of defined options, their options, and whether any variants exist
- Meta fields: A static table displaying product meta fields as key-value pairs.
- Media: Displays a list of images attached to the product.
The product field layout can be edited by going to Shopify → Settings → Products.
Fields are accessible from any product element, by their handle:
{# Native properties: #}
<h2>{{ product.title }}</h2>
<span class="price">{{ product.price|currency }}</span>
{# Custom relational field: #}
<ul class="support">
{% for article in product.relatedHelpArticles.all() %}
<li>{{ article.getLink() }}</li>
{% endfor %}
</ul>Variants and other nested records do not support custom fields.
You can give synchronized products their own on-site URLs. To set up the URI format (and the template that will be loaded when a product URL is requested), go to Shopify → Settings → Products. A URI format that emulates Shopify’s default would look something like this:
products/{handle}
Any native attribute, custom field handle, or other base element property can be used in this template to construct a URL.
Product elements’ slugs are automatically synchronized with the handle set in Shopify, so {slug} (as you might use in an entry’s URI format) is equivalent to {handle}.
If you would prefer your customers to view individual products on Shopify, clear out the Product URI Format field on the settings page, and use product.shopifyUrl instead of product.url in your templates.
A product’s status in Craft is a combination of its shopifyStatus attribute ('active', 'draft', or 'archived') and its enabled state. The former can only be changed from Shopify; the latter is set in the Craft control panel.
Note
Statuses in Craft are often a synthesis of multiple properties. For example, an entry with the Pending status just means it is enabled and has a postDate in the future.
In most cases, you’ll only want to display “Live” products, or those which are Active in Shopify and Enabled in Craft:
| Status | Shopify | Craft |
|---|---|---|
live |
Active | Enabled |
shopifyDraft |
Draft | Enabled |
shopifyArchived |
Archived | Enabled |
disabled |
Any | Disabled |
This is the default behavior when querying for products, but you can pass one of the custom Status options above to the .status() param to override it.
Products can be queried like any other element type in Craft.
A new query begins with the craft.shopifyProducts factory function:
{% set products = craft.shopifyProducts.all() %}The plugin automatically loads the relevant product when its route is requested, and makes a product variable available in the template. You only need to query for products when when they are displayed outside of this context. Product fields also return product queries.
The following element query parameters are supported, in addition to Craft’s standard set.
Note
Fields stored as JSON (like tags, options and metafields are only queryable as plain text. If you need to do advanced organization or filtering, we recommend using custom Category or Tag fields in your Product field layout.
Filter by legacy numeric Shopify product IDs.
{# Watch out—these aren't the same as element IDs! #}
{% set singleProduct = craft.shopifyProducts
.shopifyId(123456789)
.one() %}Filter by Shopify GIDs.
{# Watch out—these aren't the same as element IDs! #}
{% set singleProduct = craft.shopifyProducts
.shopifyId('gid://shopify/Product/123456789')
.one() %}This is equivalent to .shopifyId(123456789), but may be simpler if you are combining data from client-side queries.
Directly query against the product’s status in Shopify.
{% set archivedProducts = craft.shopifyProducts
.shopifyStatus('archived')
.all() %}Use the regular .status() param if you'd prefer to query against the synthesized product status values.
Warning
Note that .shopifyStatus() does not override conditions applied by the .status() param (including the defaults). You may need to call .status(null) to unset them, or use .status('shopifyDraft'), directly.
Query by the product’s handle, in Shopify.
{% set product = craft.shopifyProducts
.handle('worlds-tallest-socks')
.all() %}Warning
This is not a reliable means to fetch a specific product, as the value may change during a synchronization. If you want to store a permanent reference to a product, consider using the Shopify product field to relate it by element ID.
Find products by their “type” in Shopify.
{% set upSells = craft.shopifyProducts
.productType(['apparel', 'accessories'])
.all() %}Tags are stored as a JSON array, which may complicate direct comparisons. You may see better results using the .search() param.
{# Find products whose tags include the term in any position, with variations on casing: #}
{% set clogs = craft.shopifyProducts
.tags(['*clog*', '*Clog*'])
.all() %}Options are stored as a JSON array, which may complicate direct comparisons. You may see better results using the .search() param.
{# Find products whose options include a `size` key: #}
{% set clogs = craft.shopifyProducts
.tags('*"size"*')
.all() %}Filter by the vendor information from Shopify.
{# Find products with a vendor matching either option: #}
{% set fancyBags = craft.shopifyProducts
.vendor(['Louis Vuitton', 'Jansport'])
.all() %}Variants (ProductVariants), images (MediaImages), and meta fields (Metafields) attached to product elements are not elements themselves, and must be explicitly eager-loaded to avoid performance issues when displaying data in a loop:
{% set products = craft.shopifyProducts()
.withVariants()
.withImages()
.withMetafields()
.all() %}
<ul>
{% for product in products %}
<li>
<h2>{{ product.title }}</h2>
Available in {{ product.variants|column('title')|join(', ') }}.
{# Similar loops for each type of nested record... #}
</li>
{% endfor %}
</ul>Tip
The shorthand .withAll() is a future-proof means of eager-loading each additional type of nested record.
You can still access product.variants, product.images, and product.metafields without eager-loading—but it may result in an additional query for each kind of content. Once you’ve retrieved variants, for example, they are memoized on the product element instance for the duration of the request.
Products behave just like any other element, in Twig. Once you’ve loaded a product via a query (or have a reference to one on its template), you can output its native Shopify attributes and custom field data.
Note
Some attributes are stored as JSON, which limits nested properties’s types. As a result, dates may be slightly more difficult to work with.
{# Standard element title: #}
{{ product.title }}
{# -> Root Beer #}
{# Shopify HTML content: #}
{{ product.descriptionHtml|raw }}
{# -> <p>...</p> #}
{# Tags, as list: #}
{{ product.tags|join(', ') }}
{# -> sweet, spicy, herbal #}
{# Tags, as filter links: #}
{% for tag in tags %}
<a href="{{ siteUrl('products', { tag: tag }) }}">{{ tag|title }}</a>
{# -> <a href="https://mydomain.com/products?tag=herbal">Herbal</a> #}
{% endfor %}
{# Images: #}
{% for media in product.images %}
<img src="{{ media.image.url }}" alt="{{ media.image.altText }}">
{# -> <img src="https://cdn.shopify.com/..." alt="Bubbly Soda"> #}
{% endfor %}
{# Variants: #}
<select name="variantId">
{% for variant in product.variants %}
<option value="{{ variant.id }}">{{ variant.title }} ({{ variant.price|currency }})</option>
{% endfor %}
</select>Products don’t have a price, despite what the Shopify UI might imply—instead, every product has at least one Variant.
You can get an array (or, more accurately, a collection) of variant objects for a product by accessing product.variants or calling product.getVariants(). The product element also provides convenience methods for getting the default and cheapest variants.
- Variants are represented by a model (
craft\shopify\models\Variant), not an element. - Their native attributes reflect most of what is available via their corresponding API object; additional fields may be available within their
dataattribute. - Like products, a
metafieldsattribute provides access to additional store-defined data;
Once you have a reference to a variant, you can output any of its properties:
{% set defaultVariant = product.getDefaultVariant() %}
{{ defaultVariant.price|currency(craft.shopify.store.currency) }}Note
The currency filter is provided by Craft (not the Shopify plugin).
You must pass a three-digit ISO 4217 code to properly format a currency value.
If you are using the contextualPricingCountries setting to sync market- or currency-specific prices from the API, you may need to reach for the appropriate amount and currencyCode within the variant’s raw data.
Both the price and compareAtPrice are available for each country, under a key following this format:
{twoLetterCountryCodeLower}ContextualPricing
Each country’s object retains the shape described in the API:
{
// Other variant properties...
"usContextualPricing": {
"price": {
"amount": 14.99,
"currencyCode": "USD"
},
"compareAtPrice": {
"amount": 19.99,
"currencyCode": "USD"
}
},
"gbContextualPricing": {
"price": {
"amount": 11.99,
"currencyCode": "GBP"
},
"compareAtPrice": {
"amount": 16.99,
"currencyCode": "GBP"
}
}
}It’s up to you how markets are mapped to sites. Our original pricing output example might be made dynamic, like this:
{% set defaultVariant = product.getDefaultVariant() %}
{# Load the current site’s "country code" from a global set: #}
{% set currentMarket = shopInfo.marketCountryCode %}
{# Build the key according to the format, above: #}
{% set marketPrice = defaultVariant.data["#{currentMarket|lower}ContextualPricing"].price ?? null %}
{% if marketPrice %}
{{ marketPrice.amount|currency(marketPrice.currencyCode) }}
{% else %}
{{ defaultVariant.price|currency(defaultVariant.currencyCode) }}
{% endif %}Options are Shopify’s way of distinguishing variants in multiple dimensions. When you add product options, Shopify typically creates a variant for each combination of their possible values.
If you want to let customers pick from options instead of directly select from a list of variants, you will need to resolve which variant a given combination of options points to.
Form
<form id="add-to-cart" method="post" action="{{ craft.shopify.store.getUrl('cart/add') }}">
{# Create a hidden input to send the resolved variant ID to Shopify: #}
{{ hiddenInput('id', null, {
id: 'variant',
data: {
variants: product.variants | map(v => {
gid: v.shopifyId,
selectedOptions: v.data.selectedOptions,
}),
},
}) }}
{# Create a dropdown for each set of options: #}
{% for option in product.options %}
<label>
{{ option.name }}
{# The dropdown is tagged with the option’s `name`, so we can match it with selections, later: #}
<select data-option="{{ option.name }}">
{% for val in option.values %}
<option value="{{ val }}">{{ val }}</option>
{% endfor %}
</select>
</label>
{% endfor %}
<button id="submit">Add to Cart</button>
</form>Script
The code below can be added to a {% js %} tag or <script> element, alongside the <form>.
// Store references to <form> elements:
const $form = document.getElementById("add-to-cart");
const $variantInput = $form.elements.variant;
const $optionInputs = $form.querySelectorAll("[data-option]");
const $submit = document.getElementById('submit');
// Create a helper function to test a map of choices against variants’ selected options:
const findVariant = (choices) => {
const variants = JSON.parse($variantInput.dataset.variants);
variantLoop: for (const v in variants) {
const variant = variants[v];
// Check each selected option:
selectedOptionsLoop: for (const sel in variant.selectedOptions) {
const selectedOption = variant.selectedOptions[sel];
// Test for the presence of each chosen option on the variant:
choicesLoop: for (const name in choices) {
const choice = choices[name];
if (selectedOption.name !== name) {
// Is this option not relevant? Skip it:
continue choicesLoop;
}
if (selectedOption.value !== choice) {
// Not a value match? Bye!
continue variantLoop;
}
}
}
// Nice, the variant wasn’t skipped while inspecting its `selectedOptions`! Return it:
return variant;
}
};
// Listen for change events on the form, rather than the individual option menus:
$form.addEventListener("change", (e) => {
const choices = {};
// Loop over option menus and build an object of selected values:
$optionInputs.forEach(($input) => {
// Add the selected value, keyed by its option’s `name`:
choices[$input.dataset.option] = $input.value;
});
// Use our helper function to resolve a variant:
const variant = findVariant(choices);
if (!variant) {
console.warn("No variant exists for options:", choices);
// Disable the submit button:
$submit.disabled = true;
return;
}
console.info(`Found variant ${variant.gid}:`, choices, variant.selectedOptions);
// Assign the resolved variant’s ID to the hidden input:
$variantInput.value = variant.gid.replace('gid://shopify/ProductVariant/', '');
// Re-enable the button:
$submit.disabled = false;
});
// Trigger an initial `change` event to simulate a selection:
$form.dispatchEvent(new Event("change"));Your customers can add products to their cart directly from your Craft site by POSTing an id param containing a variant’s ID to the cart/add endpoint of your Shopify store
{% set product = craft.shopifyProducts.one() %}
<form action="{{ craft.shopify.store.getUrl('cart/add') }}" method="post">
<select name="id">
{% for variant in product.getVariants() %}
<option value="{{ variant.id }}">{{ variant.title }}</option>
{% endfor %}
</select>
{{ hiddenInput('qty', 1) }}
<button>Add to Cart</button>
</form>The JS Buy SDK is no longer maintained, and is not compatible with the new APIs or authorization scheme.
The above example can be simplified with the Buy Button JS, which provides some ready-made UI components, like a fully-featured cart. The principles are the same:
- Make products available via the appropriate sales channels in Shopify;
- Output synchronized product data in your front-end;
- Initialize, attach, or trigger SDK functionality in response to events, using Shopify-specific identifiers from step #2;
Warning
This section requires installing the Headless app and retrieving a Public access token from the app’s settings. You may also need to publish all your products into the new “storefront” created during installation.
For fully custom front-end solutions, consider the Storefront API Javascript client, which is built and maintained with the new GraphQL API in mind.
{% do view.registerJsFile('https://unpkg.com/@shopify/storefront-api-client@1.0.5/dist/umd/storefront-api-client.min.js') %}
<script>
// Note that these values are interpolated into the script tag with Twig!
const client = ShopifyStorefrontAPIClient.createStorefrontApiClient({
storeDomain: '{{ craft.shopify.settings.hostName }}',
apiVersion: '{{ craft.shopify.settings.apiVersion }}',
publicAccessToken: '{{ getenv('SHOPIFY_PUBLIC_ACCESS_TOKEN') }}',
});
</script>See the usage examples for ideas. Many queries will require Shopify identifiers, which you can output as hidden attributes:
{% for variant in product.variants %}
<button class="buy-button" data-variant-gid="{{ variant.shopifyId }}">Buy {{ variant.title }}</button>
{% endfor %}You would then consume these GIDs in JavaScript, passing them to queries via the Shopify client. Here are the two GraphQL query fragments for creating and updating a cart:
const createCartMutation = `
mutation cartCreate($input: CartInput) {
cartCreate(input: $input) {
cart {
id
}
}
}
`;
const updateCartMutation = `
mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
# Cart fields
}
userErrors {
field
message
}
warnings {
# CartWarning fields
}
}
}
`;…and the corresponding plumbing to connect those queries to the DOM elements and localStorage:
async function getCartId() {
// Have we already done this? Use an existing cart ID, if available:
if (localStorage.getItem('shopifyCartGid')) {
return localStorage.getItem('shopifyCartGid');
}
const { data, errors, extensions } = await client.request(createCartMutation, {
variables: {
input: {
// Accepted parameters are available in the documentation:
// https://shopify.dev/docs/api/storefront/2026-01/mutations/cartCreate
},
},
});
// Ok, save it for later!
localStorage.setItem('shopifyCartGid', data.cartCreate.cart.id);
return localStorage.getItem('shopifyCartGid');
}
function addItem(cartId, $el) {
const line = {
quantity: 1,
// The Shopify GID was set on the button as `data-variant-gid`:
merchandiseId: $el.dataset.variantGid,
};
return client.request(updateCartMutation, {
variables: {
cartId,
lines: [line],
},
});
}
// Find "buy buttons" and listen for clicks:
const $buyButtons = document.getElementsByClassName('buy-button');
Array.from($buyButtons).forEach(function($bb) {
$bb.addEventListener('click', function(e) {
// Ensure we have a cart ID, then add the clicked item:
getCartId()
.then(function(cartId) {
return addItem(cartId, $bb);
})
.then(console.log);
});
});Warning
This is just a slice of the required functionality for an on-site cart—the actual implementation depends largely on what features you want to offer customers, your front-end stack, and your appetite for dealing directly with the GraphQL client!
While solutions exist for creating a customized shopping experience, checkout will always happen on Shopify’s platform. This is a policy matter, not a technical limitation of the plugin (or any other integration, for that matter)—Shopify’s checkout flow is fast, reliable, secure, and familiar to many shoppers.
If you want your customers’ entire journey to be kept on-site, we encourage you to try out our powerful ecommerce plugin, Commerce.
In addition to product element methods, the plugin exposes its API to Twig via craft.shopify.
Warning
Use of API calls in Twig blocks rendering and—depending on traffic—may cause timeouts and/or failures due to rate limits. Consider using the {% cache %} tag with a key and specific expiry time to avoid making a request every time a template is rendered:
{% cache using key "shopify:collections" for 10 minutes %}
{# API calls + output... #}
{% endcache %}You can make arbitrary GraphQL queries against the GraphQL Admin API with craft.shopify.api.query():
{% set gql %}
{
collections(first: 10) {
nodes {
id
title
}
}
}
{% endset %}
{% set response = craft.shopify.api.query(gql) %}
{% set collections = response.nodes ?? [] %}
{% if collections is not empty %}
<ul>
{% for collection in collections %}
<li>{{ collection.title }}</li>
{% endfor %}
</ul>
{% endif %}This method accepts a second argument, allowing you to safely pass variables (like pagination offsets or search strings that might come from user input):
{% set gql %}
{
articles(last: $limit, query: $search) {
nodes {
id
title
summary
body
image {
url
}
}
}
}
{% endset %}
{% set response = craft.shopify.api.query(gql, {
limit: entry.shopifyArticleLimit ?? 10,
search: "blog_id:#{entry.shopifyArticleSourceBlogId}",
}) %}Refer to the Shopify API search syntax documentation for details on the query argument.
A simple URL generator is available via craft.shopify.store. You may have noticed it in the cart example, above—but it is a little more versatile than that!
{# Create a link to add a product/variant to the cart: #}
{{ tag('a', {
href: craft.shopify.store.getUrl('cart/add', {
id: variant.id,
quantity: 1,
}),
text: 'Add to Cart',
target: '_blank',
}) }}The same params argument can be passed to a product element’s getShopifyUrl() method:
{% for variant in product.getVariants() %}
<a href="{{ product.getShopifyUrl({ id: variant.id }) }}">{{ variant.title }}</a>
{% endfor %}Your store’s default currency is also available:
{{ variant.price|currency(craft.shopify.store.currency) }}Dump the entire object to see what else is available:
{{ dump(craft.shopify.store.shopSettings) }}We keep the shop’s core settings up-to-date by registering a SHOP_UPDATE webhook.
The plugin provides a Shopify Products field, which uses the familiar relational field UI to allow authors to select synchronized Product elements.
Relationships defined with the Shopify Products field use stable element IDs under the hood. When Shopify products are archived or deleted, the corresponding elements will also be updated in Craft, and naturally filtered out of your query results—including those explicitly attached via a Shopify Products field.
These fields return a product query, which you can customize using any supported query param—or immediately execute:
{% set featuredProducts = category.myProductsField.all() %}
<ul>
{% for product in featuredProducts %}
<li>{{ product.link }}</li>
{% endfor %}
</ul>This section describes advanced ways to customize the plugin’s behavior.
The following settings can also be set via a shopify.php file in your config/ directory.
| Setting | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
— | Shopify API key. |
apiSecretKey |
string |
— | Shopify API secret key. |
apiVersion |
string |
— | Shopify API version description. |
accessToken |
string |
— | Shopify API access token. |
contextualPricingCountries |
string |
— | Comma-separated list of two-letter country codes that determine which contextual prices are loaded via the API. |
hostName |
string |
— | Your store’s hostname. See the creating an app section for more information. |
uriFormat |
string |
— | Product element URI format. |
template |
string |
— | Product element template path. |
Note
Setting apiKey, apiSecretKey, apiVersion, accessToken, or hostName via shopify.php will override Project Config values set via the control panel during app setup.
You can still reference environment values from the config file with craft\helpers\App::env().
Private apps no longer come with a sales channel that allows merchants to selectively expose products to the Craft integration.
However, you can achieve similar functionality by altering the base product query, conditionally synchronizing products after they’re queried, or a combination of both. Both methods depend on setting up signifiers within Shopify, like a special category or metafield.
Two events are emitted as we build Product GraphQL queries.
The first (EVENT_DEFINE_PRODUCT_GQL_FIELDS) is used to adjust the selections (the fields that you want returned from the API).
These are most useful in combination with the selective synchronization strategy, below, when the data you need to determine eligibility is not available in the plugin’s base selection.
In the event’s example, we add selections for Shopify’s Standard Product Taxonomy.
You might instead reach for product collections by adding the collections field to your selection:
$event->fields['edges']['node']['collections'] = [
'title',
'handle',
'description',
// ...
];The second event (EVENT_DEFINE_GQL_QUERY_ARGUMENTS) allows you to manipulate arguments.
Arguments are typically used to narrow the scope of a query, as you would with an element query in Craft.
Note that Shopify collapses most of its query capabilities into a single string they call the search syntax, and the plugin already uses this when querying specific products by ID.
As the example in the event section below shows, you’ll need to account for an existing query argument, concatenating additional conditions when necessary.
The EVENT_BEFORE_SYNCHRONIZE_PRODUCT event example shows how you would achieve the same result by checking the value of a do_not_sync metafield.
We synchronize all metafield values, by default, so you do not need to modify the base product query to fetch additional fields.
Warning
This strategy may not be viable for the initial synchronization of large product catalogs that only need a small slice available in Craft. The plugin will still generate a bulk operation to fetch all product data.
Preventing a product from synchronizing only means that the plugin takes no action—a product that no longer appears in a synchronization is left as-is.
As you test synchronization criteria, you can run the shopify/data/reset command to delete all imported Product elements and start fresh.
Learn about responding to events in the Craft extension documentation.
Emitted just prior to a product element is saved with new Shopify data. The craft\shopify\events\ShopifyProductSyncEvent extends craft\events\CancelableEvent, so setting $event->isValid allows you to prevent the new data from being saved.
The event object has three properties:
element: The product element being updated.source: The Shopify product object that was applied.
use craft\base\Event;
use craft\shopify\events\ShopifyProductSyncEvent;
use craft\shopify\services\Products;
Event::on(
Products::class,
Products::EVENT_BEFORE_SYNCHRONIZE_PRODUCT,
function(ShopifyProductSyncEvent $event) {
// Example 1: Cancel the sync if a flag is set via a Shopify metafield:
$metafields = $event->element->getMetafields();
if ($metafields['do_not_sync'] ?? false) {
$event->isValid = false;
}
// Example 2: Set a custom field value from metafield data:
$event->element->setFieldValue('myNumberFieldHandle', $metafields['cool_factor']);
}
);Warning
Do not manually save changes made in this event handler.
If the event is not canceled by a handler ($event->isValid = false), the Plugin proceeds to save the element, for you.
Emitted as we build a products() GraphQL query to be executed within a bulk operation.
use craft\base\Event;
use craft\shopify\events\DefineGqlFieldsEvent;
use craft\shopify\services\Api;
Event::on(
Api::class,
Api::EVENT_DEFINE_PRODUCT_GQL_FIELDS,
function(DefineGqlFieldsEvent $event) {
// Select data for Shopify's Standard Product Taxonomy
// https://shopify.github.io/product-taxonomy/releases/2026-02/
$event->fields['edges']['node']['category'] = [
'fullName',
'id',
'name',
];
}
);Due to the way Shopify has structured its API, the main product field selections are always nested within edges.nodes.
This is also the case when crossing relationships or “connections” to other API resources (like metafields).
We do not recommend trying to reduce selection sets, as it can interfere with the plugin’s basic functions.
While the entire selection will be saved in the shopify_data table, we only split out specific objects.
If you add nested selections (like combinedListings), they will not be unpacked into additional records.
Emitted as we build any GraphQL query.
use craft\base\Event;
use craft\shopify\events\DefineGqlQueryArgumentsEvent;
use craft\shopify\services\Api;
Event::on(
Api::class,
Api::EVENT_DEFINE_GQL_QUERY_ARGUMENTS,
function(DefineGqlQueryArgumentsEvent $event) {
// Skip if we’re not querying products:
if ($event->fieldName !== 'products') {
return;
}
// For product queries only sync products that belong to a specific collection:
$syncCollectionId = 'collection_id:108179161409';
if (array_key_exists('query', $event->arguments)) {
$event->arguments['query'] .= ", {$syncCollectionId}";
} else {
$event->arguments['query'] = $syncCollectionId;
}
}
);Using this event, after the queries have been built, you have the opportunity to add custom arguments to the main query. For example, you can tailor a query for products using the ProductConnection arguments (like query, reverse, or savedSearchId).
In addition to the template helper, you can execute queries against the Admin GraphQL API via Craft’s CLI:
php craft shopify/api/query 'query { app { title } }'
# -> Running query... done! (0.463021s)
# Response:
# [
# 'title' => 'My Craft Storefront App'
# ]GraphQL only accepts double quotes (") for string literals, so you must use single quotes (') around your query, or escape double quotes with a backslash (\\").
Introspection is not currently available via the CLI; refer to the documentation for the expected structure of objects.
Errors from the API are printed to stdout.
Your synchronized products can be published into an Element API endpoint, just like any other element type. This allows you to set up a local JSON feed of products, decorated with any content you’ve added in Craft:
use craft\shopify\elements\Product;
return [
'endpoints' => [
'products.json' => function() {
return [
'elementType' => Product::class,
'criteria' => [
'publishedScope' => 'web',
'with' => [
['myImageField']
],
],
'transformer' => function(Product $product) {
$image = $product->myImageField->one();
return [
'title' => $product->title,
'variants' => $product->getVariants(),
'image' => $image ? $image->getUrl() : null,
];
},
];
},
],
];Shopify does not emit webhooks for changes in price catalogs for different markets. If you use these features, pricing stored in locally-synchronized product elements may become stale.
To fetch current prices directly from Shopify's API in your Twig templates, use the GraphQL client with a query that includes contextual pricing:
{# Define the GraphQL query with contextual pricing for a specific country #}
{% set priceQuery %}
query getProductPrice($id: ID!) {
product(id: $id) {
variants(first: 100) {
nodes {
id
title
price
compareAtPrice
gbPricing: contextualPricing(context: {country: GB}) {
price {
amount
currencyCode
}
compareAtPrice {
amount
currencyCode
}
}
}
}
}
}
{% endset %}
{# Execute the query #}
{% set response = craft.shopify.api.query(priceQuery, {
id: product.shopifyId
}) %}
{# Access the pricing data #}
{% if response %}
{% for variant in response.variants.nodes %}
{% set pricing = variant.gbPricing %}
{% if pricing and pricing.price %}
{{ pricing.price.amount|currency(pricing.price.currencyCode) }}
{% else %}
{{ variant.price|currency }}
{% endif %}
{% endfor %}
{% endif %}Key elements of this approach:
contextualPricing(context: {country: XX})returns market-specific prices (use the country code directly, e.g.,GB,US,DE)- Use an alias like
gbPricing:to name the result for easy access in Twig - Pass the product's
shopifyId(already a GID) directly to the query - The
query()method returns the first result directly, so accessresponse.variants(notresponse.data.product.variants) - Falls back to the default
variant.priceif contextual pricing is not available
Warning
Real-time API calls add latency to page rendering and count against rate limits.
If some staleness is acceptable, wrap the query in a {% cache %} tag:
{% cache using key "pricing:#{product.id}:GB" for 5 minutes %}
{# Perform the API query inside here! #}
{% endcache %}
