diff --git a/apps/docs/content/docs/anatomy/aeo-geo.mdx b/apps/docs/content/docs/anatomy/aeo-geo.mdx
index 97354a8a..25cea453 100644
--- a/apps/docs/content/docs/anatomy/aeo-geo.mdx
+++ b/apps/docs/content/docs/anatomy/aeo-geo.mdx
@@ -66,7 +66,7 @@ The `Vary: Accept` header ensures CDNs cache markdown and HTML responses separat
### What's included in the markdown
-- **Product pages** — handle, brand, category, pricing, options, variants, specs, images, tags, and SEO metadata
+- **Product pages** — handle, brand, category, pricing, exact variant count, representative selectable variants, options, specs, images, tags, and SEO metadata
- **Collection pages** — collection metadata, description, applied filters, available filters, products, pagination, image, and SEO metadata
- **Search pages** — query metadata, active collection filter, applied filters, available filters, products, and pagination state
diff --git a/apps/docs/content/docs/anatomy/agent.mdx b/apps/docs/content/docs/anatomy/agent.mdx
index e52e607d..0d3cca79 100644
--- a/apps/docs/content/docs/anatomy/agent.mdx
+++ b/apps/docs/content/docs/anatomy/agent.mdx
@@ -30,7 +30,7 @@ AgentPanel (client) → POST /api/chat → ToolLoopAgent (Claude) → Shopify to
The agent adapts its instructions based on where the user is browsing. Page context is resolved server-side from the `Referer` header:
-- **Product page** - the full product is injected into the prompt with all variants, prices, availability, and variant IDs ready for "add to cart"
+- **Product page** - the product, exact variant count, options, and a representative selectable variant set are injected into the prompt; the agent resolves exact choices on demand
- **Collection page** - the collection handle and title are included
- **Search page** - the active search query is passed through
- **Cart page** - the agent knows the user is viewing their cart
@@ -40,17 +40,18 @@ The system prompt also includes the user's locale so the agent responds in the c
## Tools
-The agent has 11 tools organized into three groups. All tools access the agent context (chat ID, cart ID, locale, page) via AsyncLocalStorage.
+The agent has 12 tools organized into three groups. All tools access the agent context (chat ID, cart ID, locale, page) via AsyncLocalStorage.
### Product discovery
-| Tool | Purpose |
-| --------------------------- | ------------------------------------------------------------------------------------- |
-| `searchProducts` | Keyword search with sorting (best matches, price low/high) - returns up to 10 results |
-| `getProductDetails` | Full product info: title, description, price, variants, availability, images |
-| `getProductRecommendations` | AI-powered similar products (up to 5) |
-| `listCollections` | All store categories |
-| `browseCollection` | Products within a collection with sorting |
+| Tool | Purpose |
+| --------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| `searchProducts` | Keyword search with sorting (best matches, price low/high) - returns up to 10 results |
+| `getProductDetails` | Product info, exact variant count, mapped options, representative variants, and bundle relationships |
+| `resolveProductVariant` | Resolves the user's exact option choices to a purchasable Shopify variant without downloading every variant |
+| `getProductRecommendations` | AI-powered similar products (up to 5) |
+| `listCollections` | All store categories |
+| `browseCollection` | Products within a collection with sorting |
### Cart management
@@ -74,10 +75,12 @@ The agent doesn't just return text - it renders rich UI components via json-rend
- **AgentProductCard** - clickable card with image, title, price, compare-at price, and availability
- **AgentProductGrid** - 2-column responsive grid wrapping multiple product cards
-- **AgentCartSummary** - full cart breakdown with line item thumbnails, subtotal, tax, total, and checkout button
+- **AgentCartSummary** - full cart breakdown with line item thumbnails, bundle components, subtotal, total, checkout-time tax note, and checkout button
- **AgentCartConfirmation** - success card shown after adding an item to cart
- **AgentVariantPicker** - display-only variant selector with option groups and availability status
+For high-variant products and Combined Listings, the picker is intentionally representative. After the shopper specifies options, the agent calls `resolveProductVariant` to obtain the exact variant ID and owning product handle before adding it to cart. Customized bundle parents that require shopper-selected components are not added by the generic agent flow.
+
These components are registered in a json-render registry and rendered client-side from specs embedded in the assistant's streamed messages.
## Cart reconciliation
diff --git a/apps/docs/content/docs/anatomy/cart.mdx b/apps/docs/content/docs/anatomy/cart.mdx
index 383e2391..83b84ada 100644
--- a/apps/docs/content/docs/anatomy/cart.mdx
+++ b/apps/docs/content/docs/anatomy/cart.mdx
@@ -55,7 +55,9 @@ The overlay content in `overlay-content.tsx` composes three sections: an empty s
## Overlay items
-Each line item in `overlay-item.tsx` reads from `cartWithPending` to reflect optimistic state. It renders the product image, title, variant options (color, size, etc.), quantity controls (+/- buttons), a remove button, and the line total. Quantity changes and removals dispatch through the cart context, which handles debouncing and server action calls.
+Each line item in `overlay-item.tsx` reads from `cartWithPending` to reflect optimistic state. It renders the product image, title, variant options (color, size, etc.), bundle components, quantity controls (+/- buttons), a remove button, and the line total. Quantity changes and removals dispatch through the cart context, which handles debouncing and server action calls.
+
+The controls honor Shopify's `CartLine.instructions.canUpdateQuantity` and `canRemove` flags instead of assuming every line is editable.
## Cart page
@@ -72,6 +74,10 @@ Below the items, a `RelatedProductsSection` shows product recommendations based
Cart operations in `lib/shopify/operations/cart.ts` use GraphQL mutations: `cartLinesAdd`, `cartLinesUpdate`, and `cartLinesRemove`. The cart ID is stored in a `shopify_cartId` HTTP-only cookie with a 7-day expiry. Every mutation calls `invalidateCartCache()` to ensure subsequent reads reflect the latest state.
+The cart fragment reads both `CartLine` and `ComponentizableCartLine`, preserving `lineComponents` as nested domain cart lines. This lets fixed and transformed bundles render as one parent with their included products. The `addToCart()` operation also accepts Shopify's optional `CartLineInput.parent` relationship as a foundation for app-specific customized bundle flows.
+
+The fragment intentionally does not query deprecated cart tax and duty amounts. Shopify finalizes those values at checkout, so storefront summaries show a taxes-and-shipping-at-checkout note instead of a misleading pre-checkout tax amount.
+
The transform layer in `lib/shopify/transforms/cart.ts` converts Shopify's GraphQL response shape into a domain `Cart` type used throughout the application. This isolates the rest of the codebase from Shopify's API structure.
## Key files
diff --git a/apps/docs/content/docs/anatomy/pages/pdp.mdx b/apps/docs/content/docs/anatomy/pages/pdp.mdx
index b5b309ba..12542e4d 100644
--- a/apps/docs/content/docs/anatomy/pages/pdp.mdx
+++ b/apps/docs/content/docs/anatomy/pages/pdp.mdx
@@ -10,28 +10,36 @@ The Product Detail Page renders a single product with variant selection, an imag
## Rendering strategy
-The PDP runs under Next.js 16 Cache Components. Product data is fetched through `"use cache"` operations with `cacheLife("max")` and a `product-{handle}` tag, so the page renders from cache on every request and is only rebuilt when a Shopify webhook invalidates the matching tag — ISR-style freshness with no per-request fetch (see [Shopify Integration](/docs/shopify) for the webhook setup).
+The PDP runs under Next.js 16 Cache Components. Base product data is fetched through a `"use cache: remote"` operation with `cacheLife("max")` and a `product-{handle}` tag. Its cache key contains only the product handle and locale, so Shopify webhooks can invalidate the matching low-cardinality product entry (see [Shopify Integration](/docs/shopify) for the webhook setup).
-The only dynamic input is the `?variant=` query parameter. Rather than block the whole page on that lookup, the route passes the `searchParams` promise — unawaited — down into `ProductDetailSection`. Anything that doesn't depend on the chosen variant (title, shared gallery images, uniformly-priced variants, the description, related products) renders straight from the cached product. Anything that does (the color-specific gallery slot, variant-specific price, option highlight, buy buttons) sits inside its own Suspense boundary so a `?variant=` change streams the affected slot without skeletonizing the rest of the page.
+The dynamic input is the selected option values in `searchParams`. The route starts the cached base-product read and a compact `getProductSelection()` request in parallel. Selection responses use `cache: "no-store"` so variant combinations do not consume persistent runtime-cache entries. `ProductDetailSection` receives both as promises and keeps variant-dependent regions in Suspense boundaries without creating a request waterfall.
## Variant selection
-Variants are selected via `searchParams`. Each option (color, size, etc.) renders as a `` pointing to the same product with Shopify's `?variant` query parameter:
+Variants are selected via option-name query parameters:
```
-/products/classic-tee?variant=123456
+/products/classic-tee?Color=Black&Size=Medium
```
-The page receives `searchParams` as a promise, threads the variant ID through to `computeSelection()` on the server, and that function returns `{ selectedOptions, selectedVariant, colorImages }` in one pass — matching the numeric variant ID to the full variant object. The result is threaded down as props with no client-side variant context or `useState`. This means:
+The route passes those values to `getProductSelection()`, whose compact fragment resolves the current variant and option state with Shopify's `selectedOrFirstAvailableVariant`, `adjacentVariants`, and encoded variant fields. `computeSelection()` combines that response with the cached base product and derives the selected option record and color images.
- Every variant selection is a navigation, giving you browser back/forward for free
- URLs are shareable - opening a link loads the exact variant
- The page is fully server-rendered with zero layout shift
- Variant links use `scroll={false}` to prevent the page from jumping to the top on selection
+- Combined Listing option links can navigate to the owning child product handle
+- Existing but out-of-stock combinations remain navigable; combinations that do not exist render as inert elements
-The `getVariantUrl()` helper in `lib/product.ts` computes the target URL for each option. When a user selects a new option value, it finds the matching variant (or falls back to the first variant with that option) and returns the URL with the correct `variant` query parameter.
+### Liquid storefront migration
-Unavailable options render as inert `` elements instead of links.
+Shopify Liquid themes use `/products/:handle?variant=:variantId` for variant deep links. A query-matched product proxy accepts those URLs as migration inputs, resolves the variant through a small `cache: "no-store"` query, and issues a permanent redirect to the normalized option-name URL before the PDP renders. This preserves indexed links, backlinks, ads, emails, and bookmarks while moving shoppers onto the headless URL format. Campaign and ad-click query parameters are preserved through the redirect.
+
+Requests without `variant` do not match the compatibility proxy, so normal PDPs remain prerenderable and pay no additional lookup. The compatibility lookup is never stored in Runtime Cache, so imported Liquid links do not create high-cardinality variant entries. Product metadata keeps the base product path as the canonical URL, consolidating variant combinations as one product for search engines.
+
+### High-variant products
+
+The product query intentionally does not request `variants(first: 50)` or any other exhaustive variant connection. Shopify supports up to 2,048 variants, so the template uses `options.optionValues.firstSelectableVariant`, `adjacentVariants`, and the selected variant as a representative set. `variantsCount` remains the exact count for structured data and agent output.
Options with a single value render as a plain `Name: Value` label without a picker — there is no choice to surface, but the attribute is still useful information to the shopper (e.g. `Finish: Natural White Oak` on a one-variant product). Shopify's synthetic `Title: Default Title` option (emitted for products with no variant axes) is the only single-value option that is hidden entirely.
@@ -47,7 +55,7 @@ Both layouts receive pre-filtered images as props from the server.
### Color-based image filtering
-When a product has color variants, the gallery shows only images relevant to the selected color. Shared images render immediately, while the color-specific slot streams behind a small Suspense fallback so changing `?variant` does not skeletonize the whole gallery. The `getPartitionedImagesForSelectedColor()` function in `lib/product.ts`:
+When a product has color variants, the gallery shows only images relevant to the selected color. Shared images render immediately, while the color-specific slot streams behind a small Suspense fallback so changing an option does not skeletonize the whole gallery. The `getPartitionedImagesForSelectedColor()` function in `lib/product.ts`:
1. Identifies the color option via swatch data or option name
2. Collects variant images assigned to each color
@@ -64,7 +72,9 @@ The buy section includes stock status and two action buttons:
- **Buy with Shop** - creates a Shopify checkout and redirects to it, styled per [Shopify's brand guidelines](https://help.shopify.com/en/manual/payments/shop-pay/assets)
- **Add to Cart** - adds the item optimistically to the cart drawer
-A single `BuyButtons` component renders identically on both mobile and desktop. It receives the `selectedVariant` as a prop and is a client component for cart interactivity.
+A single `BuyButtons` component renders identically on both mobile and desktop. It receives only the selected variant fields needed for cart interactivity; bundle relationship arrays stay in server components instead of being serialized to the client.
+
+Fixed bundle variants render their component products above the buy buttons. Component products also show the bundles returned by Shopify's `groupedBy` relationship. Customized bundle parents with `requiresComponents: true` and no fixed components show a disabled "Choose bundle items" action because their picker and component-line inputs are app-specific.
## Related Products
@@ -95,6 +105,6 @@ The page emits two JSON-LD schemas:
| `components/product-detail/product-detail-section.tsx` | Orchestrates media, options, pricing, and buy buttons with per-section Suspense; emits JSON-LD |
| `components/product-detail/product-media.tsx` | Responsive image gallery (mobile carousel, desktop grid + lightbox) |
| `components/product-detail/buy-buttons.tsx` | Buy with Shop + add-to-cart client component |
-| `lib/product.ts` | `computeSelection`, variant resolution, URL generation, color-image partitioning |
+| `lib/product.ts` | `computeSelection`, option-param parsing, and color-image partitioning |
The rest of the PDP — option pickers, price display, lightbox, schema, and so on — lives in `components/product-detail/`. Browse the directory when you need them.
diff --git a/apps/docs/content/docs/anatomy/product-card.mdx b/apps/docs/content/docs/anatomy/product-card.mdx
index 12a897ea..996c4374 100644
--- a/apps/docs/content/docs/anatomy/product-card.mdx
+++ b/apps/docs/content/docs/anatomy/product-card.mdx
@@ -8,7 +8,7 @@ Used in the home featured grid, collection (PLP) and search grids, the related-p
## Composition
-`ProductCard` is a server component that wraps the product in a `Link` to `/products/[handle]` (appending `?variant=` when a default variant id is known). Each part — image container, image, content, title, price, optional badge — exposes a `data-slot` attribute (`product-card`, `product-card-image`, etc.) as a stable styling hook.
+`ProductCard` is a server component that wraps the product in a `Link` to `/products/[handle]`. When Shopify provides default selected options, the link appends option-name query parameters such as `?Color=Black&Size=Medium`. Each part — image container, image, content, title, price, optional badge — exposes a `data-slot` attribute (`product-card`, `product-card-image`, etc.) as a stable styling hook.
## Variants
@@ -48,14 +48,10 @@ Add a hover image carousel to the product card.
Data:
- In lib/shopify/fragments.ts, add an `images(first: 5)` block (using ...ImageFields)
- to PRODUCT_CARD_FRAGMENT, and add a `variants(first: 50)` block selecting each
- variant's `image { url }`.
+ to PRODUCT_CARD_FRAGMENT.
- In lib/shopify/transforms/product.ts, restore an `images: Image[]` value on the
- object returned by transformShopifyProductCard. Compute it with a
- `filterVariantImages(product)` helper that flattens product.images, then excludes
- any image whose URL (ignoring query params) matches a non-default variant's image -
- so color-swatch variant images don't leak into the carousel. Add the matching
- `images?` and `variants?` fields back to the ShopifyProductCard response interface.
+ object returned by transformShopifyProductCard by flattening `product.images`. Add
+ the matching `images?` field to the ShopifyProductCard response interface.
- In lib/types.ts, add `images: Image[]` back to the ProductCard interface.
Component:
diff --git a/apps/docs/content/docs/reference/routes.mdx b/apps/docs/content/docs/reference/routes.mdx
index b0c9789a..35f387f4 100644
--- a/apps/docs/content/docs/reference/routes.mdx
+++ b/apps/docs/content/docs/reference/routes.mdx
@@ -9,7 +9,7 @@ type: reference
| Route | File | Description |
| ----------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------- |
| `/` | `app/page.tsx` | Home page with hero, featured products, info section |
-| `/products/[handle]` | `app/products/[handle]/page.tsx` | Product detail page; `?variant=` selects a specific variant |
+| `/products/[handle]` | `app/products/[handle]/page.tsx` | Product detail page; option-name query params select a variant |
| `/collections/[handle]` | `app/collections/[handle]/page.tsx` | Collection listing with filters, sort, pagination |
| `/collections/all` | `app/collections/[handle]/page.tsx` | Virtual "All Products" listing; synthesized since Shopify's Storefront API has no `all` collection |
| `/search` | `app/search/page.tsx` | Search results (same grid as collections) |
@@ -27,8 +27,10 @@ type: reference
## Variant URLs
-Product variant selection uses Shopify's standard `variant` query parameter directly on the product route:
+PDP option links use option-name query parameters so Shopify can resolve high-variant products and Combined Listings without loading every variant. Shopify-standard Liquid variant URLs remain valid migration inputs and permanently redirect to the equivalent option-name URL. The compatibility redirect runs in the query-matched `proxy.ts`, so ordinary PDP requests remain prerenderable:
-| URL | Purpose |
-| -------------------------------------- | --------------------------------------------------------- |
-| `/products/:handle?variant=:variantId` | Opens the product page with the matching variant selected |
+| URL | Purpose |
+| ------------------------------------------------ | ------------------------------------------------------------------ |
+| `/products/:handle?Color=Black&Size=Medium` | Normalized PDP option-selection URL |
+| `/products/:handle?variant=:variantId` | Liquid migration URL; permanently redirects to the normalized URL |
+| `/products/:combinedChild?Color=Blue&Size=Large` | Combined Listing selection using the owning child product's handle |
diff --git a/apps/docs/content/docs/reference/storefront-api.mdx b/apps/docs/content/docs/reference/storefront-api.mdx
index e5b16cf7..6758ed78 100644
--- a/apps/docs/content/docs/reference/storefront-api.mdx
+++ b/apps/docs/content/docs/reference/storefront-api.mdx
@@ -15,10 +15,10 @@ For Shopify admin setup and required token scopes, see [Storefront API Permissio
```ts
import { shopifyFetch } from "@/lib/shopify/fetch";
-const data = await shopifyFetch<{ productByHandle: ShopifyProduct }>({
+const data = await shopifyFetch<{ product: ShopifyProduct | null }>({
operation: "getProductByHandle",
query: GET_PRODUCT_BY_HANDLE_QUERY,
- variables: { handle, country, language },
+ variables: { handle, selectedOptions: [], country, language },
});
```
@@ -52,10 +52,11 @@ const GET_PRODUCT_BY_HANDLE_QUERY = `
${PRODUCT_FRAGMENT}
query getProductByHandle(
$handle: String!
+ $selectedOptions: [SelectedOptionInput!]!
$country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
- productByHandle(handle: $handle) {
+ product(handle: $handle) {
...ProductFields
}
}
@@ -70,13 +71,16 @@ Always verify fields against the live Storefront API schema with `shopify-ai-too
Shared fragments in `lib/shopify/fragments.ts` avoid repeating field selections:
-| Fragment | Covers |
-| --------------------------- | ---------------------------------------------------------------------------------------------- |
-| `MONEY_FRAGMENT` | `amount` and `currencyCode` on `MoneyV2` |
-| `IMAGE_FRAGMENT` | `url`, `altText`, `width`, `height` on `Image` |
-| `PRODUCT_VARIANT_FRAGMENT` | Variant price, options, image, availability |
-| `PRODUCT_FRAGMENT` | Full product: media, variants (up to 50), options with swatches, metafields, price ranges, SEO |
-| `CATEGORY_PRODUCT_FRAGMENT` | Lightweight product for listing pages (fewer fields) |
+| Fragment | Covers |
+| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| `MONEY_FRAGMENT` | `amount` and `currencyCode` on `MoneyV2` |
+| `IMAGE_FRAGMENT` | `url`, `altText`, `width`, `height` on `Image` |
+| `PRODUCT_VARIANT_FRAGMENT` | Representative variant price, options, image, availability, and owning product handle |
+| `PURCHASABLE_PRODUCT_VARIANT_FRAGMENT` | Selected variant plus `requiresComponents`, fixed bundle `components`, and reverse `groupedBy` bundle relationships |
+| `PRODUCT_SELECTION_FRAGMENT` | Modern high-variant and Combined Listing selection fields |
+| `PRODUCT_FRAGMENT` | Full product metadata, media, exact variant count, modern selection data, price ranges, taxonomy, collections, and SEO |
+| `CART_FRAGMENT` | Ordinary and componentizable cart lines, nested bundle components, line instructions, costs, and delivery groups |
+| `CATEGORY_PRODUCT_FRAGMENT` | Lightweight product for listing pages (fewer fields) |
Embed fragments in a query with template literal interpolation: `` `${PRODUCT_FRAGMENT} query ...` ``.
@@ -102,19 +106,20 @@ export async function getProduct({
cacheLife("max");
cacheTag("products", `product-${handle}`);
- const data = await shopifyFetch<{ productByHandle: ShopifyProduct }>({
+ const data = await shopifyFetch<{ product: ShopifyProduct | null }>({
operation: "getProductByHandle",
query: GET_PRODUCT_BY_HANDLE_QUERY,
variables: {
handle,
+ selectedOptions: [],
country: getCountryCode(locale),
language: getLanguageCode(locale),
},
});
- if (!data.productByHandle) return undefined;
+ if (!data.product) return undefined;
- return transformShopifyProductDetails(data.productByHandle);
+ return transformShopifyProductDetails(data.product);
}
```
@@ -127,7 +132,7 @@ Key elements:
## Caching
-Read operations use `"use cache"` with `cacheLife("max")` and one or more cache tags when their inputs are stable, such as product detail, collection, menu, sitemap, and recommendation queries. High-cardinality operations that depend on search, filters, sort, or cursors use `"use cache: remote"` instead. Tags follow a hierarchy:
+Read operations use `"use cache"` with `cacheLife("max")` and one or more cache tags when their inputs are stable, such as base product detail, collection, menu, sitemap, and recommendation queries. High-cardinality operations that depend on selected options, search, filters, sort, or cursors use `"use cache: remote"` instead. Tags follow a hierarchy:
| Tag pattern | Scope |
| -------------------------- | ------------------------------------ |
@@ -161,6 +166,25 @@ export async function addToCart(lines: CartLineInput[], cartId: string, locale:
Shopify returns the full updated cart in every mutation response, so there's no need for a follow-up query.
+`addToCart()` accepts Shopify's optional `CartLineInput.parent` relationship for customized bundle and add-on flows. The default PDP only adds ordinary products and fixed bundle parents directly; customized component selection remains app-specific.
+
+The cart fragment omits deprecated tax and duty amount fields. Shopify finalizes those values at checkout, so the storefront presents a checkout-time tax note instead.
+
+## Product selection
+
+The template does not query an exhaustive variants connection. The PDP starts a low-cardinality cached `getProduct()` read and an uncached `getProductSelection()` request in parallel. The selection operation uses:
+
+- `selectedOrFirstAvailableVariant(selectedOptions:)`
+- `adjacentVariants(selectedOptions:)`
+- `options.optionValues.firstSelectableVariant`
+- `encodedVariantExistence` and `encodedVariantAvailability`
+
+This avoids a historical `first: 50` cap, scales to Shopify's 2,048-variant product limit, and lets option values navigate across Combined Listing child product handles. `variantsCount.count` is used whenever the exact count is needed.
+
+`getProductSelection()` uses `cache: "no-store"` because selected option combinations have high cardinality. It is also used by tools that need to resolve an exact option combination, such as the shopping agent. Product cards and PDP option links use option-name query parameters rather than numeric variant IDs.
+
+Inbound Shopify Liquid links using `?variant=:variantId` are resolved by a separate uncached `node(id:)` query in the query-matched product proxy. The proxy permanently redirects to the equivalent option-name URL while preserving attribution parameters. Requests without `variant` bypass that proxy matcher, so normal PDP prerendering and cached base-product reads are unchanged. Product metadata continues to canonicalize variants to the base product path, preserving migration SEO without storing variant-ID entries in Runtime Cache.
+
## Error handling
The client handles errors at two levels:
@@ -184,10 +208,11 @@ Set `DEBUG_SHOPIFY=true` to log every request with timing and a variable preview
Every operation converts the Shopify response to a domain type before returning. Transforms live in `lib/shopify/transforms/`:
-| Transform | Input → Output |
-| -------------------------------- | ----------------------------------- |
-| `transformShopifyProductDetails` | `ShopifyProduct` → `ProductDetails` |
-| `transformShopifyCart` | `ShopifyCart` → `Cart` |
+| Transform | Input → Output |
+| ---------------------------------- | -------------------------------------------------- |
+| `transformShopifyProductDetails` | `ShopifyProduct` → `ProductDetails` |
+| `transformShopifyProductSelection` | `ShopifyProductSelection` → `ProductSelectionData` |
+| `transformShopifyCart` | `ShopifyCart` → `Cart` |
Domain types are defined in `lib/types.ts` and are provider-agnostic. Components import these types, never the Shopify-specific response shapes. This keeps the UI decoupled from the API layer.
diff --git a/apps/docs/content/docs/reference/troubleshooting.mdx b/apps/docs/content/docs/reference/troubleshooting.mdx
index b86f58cd..79677066 100644
--- a/apps/docs/content/docs/reference/troubleshooting.mdx
+++ b/apps/docs/content/docs/reference/troubleshooting.mdx
@@ -19,9 +19,13 @@ The cart ID is stored in a `shopify_cartId` HTTP-only cookie with a 7-day expiry
## Variant selection not working
-Variant selection uses Shopify's standard `?variant=123` query parameter. If option links change the URL but the page does not update, check that `app/products/[handle]/page.tsx` reads `searchParams.variant` and passes it to the product detail components.
+PDP option links use option-name query parameters such as `?Color=Black&Size=Medium`. If option links change the URL but the page does not update, check that `app/products/[handle]/page.tsx` passes those values to the uncached `getProductSelection()` operation and that the query includes `selectedOrFirstAvailableVariant`, `adjacentVariants`, and the encoded variant fields.
-Also confirm variant links are generated with `getVariantUrl()` from `lib/product.ts`, which should return `/products/:handle?variant=:variantId` for variant-specific links.
+For Combined Listings, confirm the generated option link can change the product handle to the selected variant's owning product.
+
+Existing Liquid links such as `?variant=123` should permanently redirect to an option-name URL. If they do not, verify the query matcher in `proxy.ts` is preserved, `getProductVariantRouteSelection()` can resolve the numeric ID, and the Storefront token can access the variant.
+
+If every option appears unavailable, verify the local encoded-variant decoder still matches Shopify's current `v1_` encoding and that `options.optionValues` are queried in Shopify's option order.
## Images not loading
diff --git a/apps/docs/content/docs/shopify/index.mdx b/apps/docs/content/docs/shopify/index.mdx
index a035641a..0e0cb56d 100644
--- a/apps/docs/content/docs/shopify/index.mdx
+++ b/apps/docs/content/docs/shopify/index.mdx
@@ -14,14 +14,14 @@ For the API client and query patterns, see [Storefront API](/docs/reference/stor
## Concept mapping
-| Shopify concept | Template route | What renders it |
-| --------------- | ----------------------- | ----------------------------------------------------------------- |
-| Products | `/products/[handle]` | Product detail page with variants, gallery, buy section |
-| Collections | `/collections/[handle]` | Product grid with filtering, sorting, pagination |
-| Menus | Navigation, footer | Quick links bar (with hover panels), mobile sheet, footer columns |
-| Pages | `/pages/[slug]` | Marketing page renderer (requires CMS setup) |
-| Search | `/search` | Same grid as collections, driven by query param |
-| Cart | `/cart` + overlay | Cart context with optimistic updates |
+| Shopify concept | Template route | What renders it |
+| --------------- | ----------------------- | ------------------------------------------------------------------ |
+| Products | `/products/[handle]` | High-variant and Combined Listing PDP with gallery and buy section |
+| Collections | `/collections/[handle]` | Product grid with filtering, sorting, pagination |
+| Menus | Navigation, footer | Quick links bar (with hover panels), mobile sheet, footer columns |
+| Pages | `/pages/[slug]` | Marketing page renderer (requires CMS setup) |
+| Search | `/search` | Same grid as collections, driven by query param |
+| Cart | `/cart` + overlay | Optimistic cart with componentized bundle lines |
## Required menus
diff --git a/apps/docs/content/docs/shopify/pdp.mdx b/apps/docs/content/docs/shopify/pdp.mdx
index 8e48a05f..de60bbf7 100644
--- a/apps/docs/content/docs/shopify/pdp.mdx
+++ b/apps/docs/content/docs/shopify/pdp.mdx
@@ -16,7 +16,29 @@ The PDP renders any product that has a handle (URL slug) in Shopify. At minimum,
- At least one **variant** (even single-option products have a default variant)
- A **featured image** and optionally up to 10 media items (images or videos)
-The template fetches up to 50 variants per product and 10 media items.
+The template fetches up to 10 media items. It does not fetch a capped `variants(first: ...)` connection.
+
+## Modern product model
+
+The PDP uses Shopify's Storefront API 2026-04 product-selection fields rather than downloading every variant:
+
+- `variantsCount` reports the exact number of variants.
+- `options.optionValues` provides option values, swatches, and a first selectable variant.
+- `encodedVariantExistence` and `encodedVariantAvailability` determine whether each option combination exists and is available.
+- `selectedOrFirstAvailableVariant(selectedOptions:)` resolves the selected purchasable variant.
+- `adjacentVariants(selectedOptions:)` provides the nearby variants needed to update option state.
+
+This model supports Shopify's 2,048-variant product limit without truncating the picker at 50 variants. It also supports Combined Listings: an option value can resolve to a variant owned by another product, and the generated option link uses that product's handle.
+
+## Bundles
+
+For the selected variant, the PDP queries Shopify's bundle relationships:
+
+- `components` lists the items in a fixed bundle.
+- `groupedBy` lists up to 10 bundles that contain the selected component.
+- `requiresComponents` identifies customized bundle parents that cannot be purchased alone.
+
+Fixed bundles render their components and can be added normally. A customized bundle that requires shopper-selected components is disabled by the generic buy buttons until you add the app-specific bundle configuration UI and send the component lines through `CartLineInput.parent`.
## Metafields
@@ -74,9 +96,9 @@ Product recommendations on the PDP are powered by Shopify's recommendation API.
## Key files
-| File | Purpose |
-| ------------------------------------ | --------------------------------------------------------------- |
-| `lib/shopify/fragments.ts` | `PRODUCT_FRAGMENT` and `METAFIELD_FRAGMENT` |
-| `lib/shopify/transforms/product.ts` | Metafield label mapping and product transform |
-| `lib/shopify/operations/products.ts` | Product fetch operations with caching |
-| `lib/product.ts` | Variant resolution, swatch detection, and option URL generation |
+| File | Purpose |
+| ------------------------------------ | -------------------------------------------------- |
+| `lib/shopify/fragments.ts` | `PRODUCT_FRAGMENT` and `METAFIELD_FRAGMENT` |
+| `lib/shopify/transforms/product.ts` | Metafield label mapping and product transform |
+| `lib/shopify/operations/products.ts` | Product fetch operations with caching |
+| `lib/product.ts` | Selection composition and color-image partitioning |
diff --git a/apps/docs/content/docs/skills/enable-shopify-markets.mdx b/apps/docs/content/docs/skills/enable-shopify-markets.mdx
index c84ab92f..7201fb58 100644
--- a/apps/docs/content/docs/skills/enable-shopify-markets.mdx
+++ b/apps/docs/content/docs/skills/enable-shopify-markets.mdx
@@ -358,7 +358,7 @@ export function proxy(request: NextRequest) {
}
```
-> **Note:** Product variant selection stays on Shopify's standard `?variant=` query parameter. The built-in content negotiation rewrite in `next.config.ts` handles markdown negotiation automatically — no proxy.ts changes needed.
+> **Note:** Extend the template's existing `proxy.ts`; do not replace its Liquid `?variant=` compatibility branch. Run that branch with the locale parsed from the product pathname before delegating to next-intl. The variant lookup remains query-gated and uncached, while the built-in content negotiation rewrite in `next.config.ts` continues to handle markdown negotiation.
---
@@ -576,7 +576,7 @@ After completing all steps, verify the implementation:
- Locale-prefixed URL works (e.g., `http://localhost:3000/de-DE/products/technest-smart-speaker-pro-jk0c`)
- Product prices render in the correct currency for each locale
3. **Locale selector**: Confirm the selector appears in the megamenu and switching locales changes the URL + cart currency
-4. **Variants**: Confirm product variant links preserve `?variant=` across locale-prefixed URLs
+4. **Variants**: Confirm option-name query params survive locale-prefixed navigation and Liquid `?variant=` links redirect within the active locale
5. **SEO**: Check that page metadata includes `hreflang` alternates for all enabled locales
6. **Sitemap**: Visit `/sitemap.xml` and confirm per-locale entries
diff --git a/apps/template/app/products/[handle]/page.tsx b/apps/template/app/products/[handle]/page.tsx
index 3ebc8e6e..714320ae 100644
--- a/apps/template/app/products/[handle]/page.tsx
+++ b/apps/template/app/products/[handle]/page.tsx
@@ -7,9 +7,13 @@ import { Container } from "@/components/ui/container";
import { Page } from "@/components/ui/page";
import { Sections } from "@/components/ui/sections";
import { getLocale } from "@/lib/params";
-import { computeSelection } from "@/lib/product";
+import { computeSelection, getSelectedOptionsFromSearchParams } from "@/lib/product";
import { buildAlternates, buildOpenGraph } from "@/lib/seo";
-import { getCatalogProducts, getProduct } from "@/lib/shopify/operations/products";
+import {
+ getCatalogProducts,
+ getProduct,
+ getProductSelection,
+} from "@/lib/shopify/operations/products";
const PLACEHOLDER_HANDLE = "__placeholder__";
@@ -84,17 +88,21 @@ export default async function ProductPage({
if (handle === PLACEHOLDER_HANDLE) notFound();
return handle;
});
-
- const productPromise = handlePromise.then(async (handle) => {
- const product = await getProduct({ handle, locale });
- if (!product) notFound();
- return product;
- });
-
- const variantIdPromise = searchParams.then((sp) => sp?.variant as string | undefined);
-
- const selectionPromise = Promise.all([productPromise, variantIdPromise]).then(
- ([product, variantId]) => computeSelection(product, variantId),
+ const selectedOptionsPromise = searchParams.then(getSelectedOptionsFromSearchParams);
+ const productPromise = handlePromise.then((handle) =>
+ getProduct({ handle, locale }).then((product) => {
+ if (!product) notFound();
+ return product;
+ }),
+ );
+ const selectionDataPromise = Promise.all([handlePromise, selectedOptionsPromise]).then(
+ ([handle, selectedOptions]) =>
+ selectedOptions.length > 0
+ ? getProductSelection({ handle, selectedOptions, locale })
+ : undefined,
+ );
+ const selectionPromise = Promise.all([productPromise, selectionDataPromise]).then(
+ ([product, selectionData]) => computeSelection(product, selectionData),
);
return (
diff --git a/apps/template/components/agent/agent-panel.tsx b/apps/template/components/agent/agent-panel.tsx
index 262dabdc..7348725d 100644
--- a/apps/template/components/agent/agent-panel.tsx
+++ b/apps/template/components/agent/agent-panel.tsx
@@ -145,6 +145,7 @@ function getToolStepStatus(state: string): "complete" | "active" | "pending" {
const TOOL_METADATA: Record = {
searchProducts: { label: "Searching products", icon: SearchIcon },
getProductDetails: { label: "Looking up product details", icon: PackageIcon },
+ resolveProductVariant: { label: "Resolving product options", icon: PackageIcon },
getProductRecommendations: {
label: "Finding recommendations",
icon: SparklesIcon,
diff --git a/apps/template/components/agent/registry.tsx b/apps/template/components/agent/registry.tsx
index 71a06b7a..7dcb0a29 100644
--- a/apps/template/components/agent/registry.tsx
+++ b/apps/template/components/agent/registry.tsx
@@ -80,7 +80,6 @@ export const { registry } = defineRegistry(catalog, {
const locale = useLocale();
const tCart = useTranslations("cart");
const subtotal = parsePriceString(props.subtotal);
- const tax = parsePriceString(props.tax);
const total = parsePriceString(props.total);
return (
@@ -119,6 +118,21 @@ export const { registry } = defineRegistry(catalog, {
{item.options}
)}
Qty: {item.quantity}
+ {item.components.length > 0 && (
+