1 # Shopify Code Templates
2
3 These are reference snippets. Adapt them to the app's routing, framework, and state patterns.
4
5 For end-to-end catalog seeding, prefer copying the known-good `./seed-products.mjs` golden script (it wires the snippets below together in the correct order with the required `@idempotent` directives and re-run safety) rather than hand-assembling the sequence from these snippets.
6
7 Admin setup scripts are orchestration only and should not run during integration onboarding. Only create products/inventory after the user explicitly asks for catalog setup. Scripts should resolve publication and location IDs dynamically, check every mutation's `userErrors`, and write only stable mapping data (handles, product IDs, variant IDs) after Shopify confirms success. Every Admin API call must go through the OpenInt proxy helper; do not call Shopify Admin endpoints directly or handle Admin tokens in generated app code.
8
9 ## Create, price, stock, and publish a product
10
11 Use `shopify-admin-api.mjs` through the OpenInt proxy.
12
13 Do not replace this helper with a direct `https://{shop_domain}/admin/...` request, Shopify Admin SDK client, or custom OAuth/token flow. OpenInt owns Shopify Admin authentication.
14
15 Make seed scripts safe to re-run: `productCreate` does **not** support the `@idempotent` directive, so look the product up by its stable `handle` first and reuse the existing product/variant/inventoryItem IDs instead of creating a duplicate. Only call `productCreate` when the handle does not already exist.
16
17 ```graphql
18 query ProductByHandle($handle: String!) {
19 productByHandle(handle: $handle) {
20 id
21 variants(first: 1) { nodes { id inventoryItem { id } } }
22 }
23 }
24 ```
25
26 ```bash
27 node shopify-admin-api.mjs '{
28 "query": "mutation ProductCreate($product: ProductCreateInput!) { productCreate(product: $product) { product { id title handle } userErrors { field message } } }",
29 "variables": {
30 "product": {
31 "title": "Example T-shirt",
32 "handle": "example-t-shirt",
33 "descriptionHtml": "<p>Soft cotton shirt.</p>",
34 "status": "ACTIVE"
35 }
36 }
37 }'
38 ```
39
40 After product creation, query variants and inventory item IDs. The variant ID is the cart `merchandiseId`; the inventory item ID is needed to set stock:
41
42 ```graphql
43 query ProductVariants($productId: ID!) {
44 product(id: $productId) {
45 variants(first: 20) {
46 nodes {
47 id
48 title
49 price
50 availableForSale
51 inventoryItem { id }
52 }
53 }
54 }
55 }
56 ```
57
58 Set price and inventory before publishing. Use `productVariantsBulkUpdate` for prices:
59
60 ```graphql
61 mutation ProductVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
62 productVariantsBulkUpdate(productId: $productId, variants: $variants) {
63 productVariants { id price }
64 userErrors { field message }
65 }
66 }
67 ```
68
69 For inventory, run three steps in order before any quantity write — newly created products are not tracked or activated at a location by default, so `inventorySetQuantities` against an inert inventory item silently leaves storefront stock unenforced or returns `userErrors`.
70
71 1. Enable tracking on the inventory item so quantity changes have effect:
72
73 ```graphql
74 mutation InventoryItemUpdate($id: ID!, $input: InventoryItemInput!) {
75 inventoryItemUpdate(id: $id, input: $input) {
76 inventoryItem { id tracked }
77 userErrors { field message }
78 }
79 }
80 ```
81
82 Variables (one call per inventory item):
83
84 ```json
85 { "id": "<inventoryItem.id>", "input": { "tracked": true } }
86 ```
87
88 1. Activate the inventory item at the resolved location so a stock level exists there. Shopify **requires** the `@idempotent` directive on this mutation — attach it at the **field** level (on `inventoryActivate`, not on the operation) with a stable per-item key so a retried seed does not double-apply or get rejected with `The @idempotent directive is required for this mutation but was not provided`:
89
90 ```graphql
91 mutation InventoryActivate($inventoryItemId: ID!, $locationId: ID!) {
92 inventoryActivate(inventoryItemId: $inventoryItemId, locationId: $locationId)
93 @idempotent(key: "activate-<stable-per-item-key>") {
94 inventoryLevel { id }
95 userErrors { field message }
96 }
97 }
98 ```
99
100 1. Set the actual quantity with a compare-and-set adjustment. Read the current `available` level first, then apply the delta with `inventoryAdjustQuantities`, which **requires** the `@idempotent` directive at the **field** level with a stable per-item key. The per-change `changeFromQuantity` is the CAS field — pass the value you just read so a concurrent change fails loudly instead of silently clobbering (pass `null` to skip the check):
101
102 ```graphql
103 query InventoryLevel($id: ID!, $locationId: ID!) {
104 inventoryItem(id: $id) {
105 inventoryLevel(locationId: $locationId) {
106 quantities(names: ["available"]) { name quantity }
107 }
108 }
109 }
110 ```
111
112 ```graphql
113 mutation InventoryAdjustQuantities($input: InventoryAdjustQuantitiesInput!) {
114 inventoryAdjustQuantities(input: $input)
115 @idempotent(key: "setqty-<stable-per-item-key>") {
116 inventoryAdjustmentGroup { id }
117 userErrors { field message }
118 }
119 }
120 ```
121
122 Variables (one change per inventory item; `delta = target - current`):
123
124 ```json
125 {
126 "input": {
127 "name": "available",
128 "reason": "correction",
129 "changes": [
130 {
131 "inventoryItemId": "<inventoryItem.id>",
132 "locationId": "<location.id>",
133 "delta": 100,
134 "changeFromQuantity": 0
135 }
136 ]
137 }
138 }
139 ```
140
141 `inventorySetQuantities` is an alternative that sets an absolute value, but on API version `2026-04` it dropped `ignoreCompareQuantity` and requires a per-item `changeFromQuantity` (pass `null` to skip the CAS check) plus the same `@idempotent` directive — prefer the `inventoryAdjustQuantities` delta form above.
142
143 Use a stable key derived from the item (e.g. the product handle), not a random value, so re-running the seed is safe. Always inspect `userErrors` on each of these and stop on the first non-empty array; the activation step in particular can fail if `read_locations` / `write_inventory` scopes are missing or the location ID does not belong to the store.
144
145 Resolve the publication ID at runtime; the connection payload does not expose one. Prefer the Replit-owned channel: the installed Replit Sales Channel App is a channel app, so its own publication is authoritative — resolve it directly via `currentAppInstallation` (this is how the Replit `shopify-store` connector identifies it):
146
147 ```graphql
148 query AppPublication {
149 currentAppInstallation {
150 publication { id }
151 }
152 }
153 ```
154
155 If `currentAppInstallation.publication` is absent, fall back to the publication named exactly `Online Store` so the storefront is not empty:
156
157 ```graphql
158 query Publications {
159 publications(first: 20) {
160 nodes { id name }
161 }
162 }
163 ```
164
165 Match the **exact** name `Online Store` — do not match a broad `Sales Channel` substring (it catches unrelated channels like `Google & YouTube Sales Channel`). On a transferred live merchant store, publishing to `Online Store` makes products visible on their existing storefront, so gate that case behind explicit user confirmation. If neither publication exists, skip publishing and tell the user the Sales Channel publication is not provisioned yet.
166
167 Then publish with `publishablePublish`, after price and inventory are configured:
168
169 ```graphql
170 mutation Publish($productId: ID!, $publicationId: ID!) {
171 publishablePublish(id: $productId, input: [{ publicationId: $publicationId }]) {
172 publishable { publishedOnPublication(publicationId: $publicationId) }
173 userErrors { field message }
174 }
175 }
176 ```
177
178 If no suitable publication exists, skip publishing and tell the user the Shopify Sales Channel publication is not yet provisioned for this connection.
179
180 Resolve inventory location dynamically; do not hardcode location IDs:
181
182 ```graphql
183 query Locations {
184 locations(first: 1) {
185 nodes { id name }
186 }
187 }
188 ```
189
190 ## Query products in app code
191
192 The helper fetches the Shopify Store connection from OpenInt with `include_secrets=true`, extracts only `shop_domain` and `storefront_access_token` from `items[0].settings`, pins the Storefront API version in code, and calls Shopify Storefront API directly with the connector-provided Storefront token. Keep this helper server-side behind API routes, server actions, SSR-only modules, or setup scripts; browser UI should call your server boundary. App code should not ask the user to paste tokens or route buyer Storefront traffic through OpenInt by default.
193
194 ```ts
195 import { shopifyStorefrontRequest } from "./shopifyStorefrontClient";
196
197 const PRODUCTS_QUERY = `#graphql
198 query Products {
199 products(first: 12) {
200 nodes {
201 id
202 title
203 handle
204 featuredImage { url altText }
205 priceRange { minVariantPrice { amount currencyCode } }
206 variants(first: 10) { nodes { id title availableForSale } }
207 }
208 }
209 }
210 `;
211
212 export async function getProducts() {
213 return shopifyStorefrontRequest<{
214 products: { nodes: Array<unknown> };
215 }>(PRODUCTS_QUERY);
216 }
217 ```
218
219 ## Create a cart and redirect to Shopify checkout
220
221 The `variantId` below is the Shopify ProductVariant GID from the Storefront product query (`products.nodes[].variants.nodes[].id`) or from Admin GraphQL after creating the product. It is the value Shopify expects as `merchandiseId`.
222
223 ```ts
224 import { shopifyStorefrontRequest } from "./shopifyStorefrontClient";
225
226 const CART_CREATE = `#graphql
227 mutation CartCreate($variantId: ID!, $quantity: Int!) {
228 cartCreate(input: { lines: [{ merchandiseId: $variantId, quantity: $quantity }] }) {
229 cart { id checkoutUrl }
230 userErrors { field message }
231 }
232 }
233 `;
234
235 export async function createCheckoutUrl(
236 variantId: string,
237 quantity = 1,
238 // `useDevStorePreview` is caller-controlled. The Shopify Store connection
239 // does not expose a dev-store flag; pass true when running against a
240 // Vibe/dev preview environment and false (default) for live storefronts.
241 options: { useDevStorePreview?: boolean } = {},
242 ) {
243 const data = await shopifyStorefrontRequest<{
244 cartCreate: {
245 cart: { checkoutUrl: string } | null;
246 userErrors: Array<{ message: string }>;
247 };
248 }>(CART_CREATE, { variantId, quantity });
249
250 const error = data.cartCreate.userErrors[0];
251 if (error) {
252 throw new Error(error.message);
253 }
254
255 if (!data.cartCreate.cart?.checkoutUrl) {
256 throw new Error("Shopify did not return a checkout URL");
257 }
258
259 const checkoutUrl = new URL(data.cartCreate.cart.checkoutUrl);
260 if (options.useDevStorePreview) {
261 // Vibe-created development stores are password protected. Shopify's Vibe
262 // docs recommend this preview parameter so checkout links work before the
263 // merchant claims/transfers the store. Disable this for live stores.
264 checkoutUrl.searchParams.set("channel", "online_store");
265 }
266
267 return checkoutUrl.toString();
268 }
269 ```
270
271 Do not replace Storefront cart checkout with Admin API draft orders. Admin API is for setup/product writes; buyers should use Storefront cart + Shopify-hosted checkout.
272
273 If opening `checkoutUrl` hits the dev-store password page, keep the real cart and add `channel=online_store`. If `cartCreate` itself returns 401/403, refetch the Shopify Store connection settings once and retry with the direct Shopify Storefront API helper above; do not ask the user to paste a token.
274
275 ## Go Live behavior
276
277 Do not bake transfer or launch steps into generated storefront code. Go Live is an integration-management workflow:
278
279 - The merchant explicitly clicks/asks for Go Live.
280 - The connection payload does not expose a claim link. Direct the merchant to Replit's Shopify integration management surface by UI navigation rather than a `replit.com` URL: open the **Integrations** tab, go to **Shopify** and click **Manage**, and initiate the transfer there. (That surface is environment-correct in dev/staging/prod; a hardcoded host would send staging/local sessions to the wrong environment.) Do not have generated app code call an OpenInt connector RPC directly to mint a claim link. Surface `pending_transfer` status from the connection if useful.
281 - If the merchant provides first and last name, the Go Live surface can pass them along with the transfer request; otherwise Replit/OpenInt may derive a minimal display name from the email for Shopify's transfer API. Do not collect or persist raw Shopify credentials in generated app code.
282 - The generated app keeps reading the same Shopify connection. Dev-store preview behavior (`channel=online_store`) is caller-controlled — toggle it off once the app is targeting the live storefront.
283 - If Shopify Admin actions start returning reauthorization-required status after transfer, ask the merchant to reconnect Shopify rather than creating a new connection.
284 - Taxes, payments KYC, plan selection, and storefront password removal stay merchant-owned actions in Shopify.