Commit 9a014727a0b for woocommerce

commit 9a014727a0b8d822a2e4ce51d8a28682e6c0feb4
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Fri May 22 18:56:09 2026 +0100

    Merge `feature/shopper-collections` into `trunk` (#65263)

diff --git a/plugins/woocommerce/changelog/64771-fix-shopper-collection-variation-hydration b/plugins/woocommerce/changelog/64771-fix-shopper-collection-variation-hydration
new file mode 100644
index 00000000000..f5714cbdd5a
--- /dev/null
+++ b/plugins/woocommerce/changelog/64771-fix-shopper-collection-variation-hydration
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Interactivity API hydration warning on shopper-collection rows for items without product variations.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/64776-fix-cart-save-for-later-visibility b/plugins/woocommerce/changelog/64776-fix-cart-save-for-later-visibility
new file mode 100644
index 00000000000..93ec66a8957
--- /dev/null
+++ b/plugins/woocommerce/changelog/64776-fix-cart-save-for-later-visibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Hide the cart "Save for later" link when the user is logged out, the `cart_save_for_later` feature is disabled, or the cart page has no `woocommerce/shopper-collection` block.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/64807-hide-non-public-products-in-lists b/plugins/woocommerce/changelog/64807-hide-non-public-products-in-lists
new file mode 100644
index 00000000000..71770ab623e
--- /dev/null
+++ b/plugins/woocommerce/changelog/64807-hide-non-public-products-in-lists
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Don't leak title, price, permalink, or image for non-public products (draft, private, password-protected, etc.) in shopper-list responses.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/65179-refactor-saved-for-later-standalone b/plugins/woocommerce/changelog/65179-refactor-saved-for-later-standalone
new file mode 100644
index 00000000000..d8c1b9ce22d
--- /dev/null
+++ b/plugins/woocommerce/changelog/65179-refactor-saved-for-later-standalone
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Saved for Later: split standalone block from the Shopper Collection variation; shared shopper-lists REST + iAPI domain remains reusable for future list-type blocks.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/add-add-to-wishlist-button-block b/plugins/woocommerce/changelog/add-add-to-wishlist-button-block
new file mode 100644
index 00000000000..6150790e608
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-add-to-wishlist-button-block
@@ -0,0 +1,5 @@
+Significance: minor
+Type: add
+Comment: Experimental Add to Wishlist button block, gated by the `product_wishlist` feature flag.
+
+Add the `woocommerce/add-to-wishlist-button` block: shipped as an inner block of `woocommerce/add-to-cart-with-options` on the single-product template (block themes only). Toggles the currently configured product (parent or selected variation) in the shopper's wishlist via the shared `shopper-lists` iAPI store. Hidden for guests.
diff --git a/plugins/woocommerce/changelog/add-cart-save-for-later-link b/plugins/woocommerce/changelog/add-cart-save-for-later-link
new file mode 100644
index 00000000000..076b97eb734
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-cart-save-for-later-link
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a "Save for later" link beneath each cart line item that saves the item to the saved-for-later shopper list and removes it from the cart on success. The link is only shown to logged-in users and is hidden in the mini-cart.
diff --git a/plugins/woocommerce/changelog/add-shopper-collection-heading b/plugins/woocommerce/changelog/add-shopper-collection-heading
new file mode 100644
index 00000000000..c9544bf9494
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collection-heading
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Shopper Collection: add a built-in heading inner block (defaults to "Saved for later" at H2). Editable in the block editor.
diff --git a/plugins/woocommerce/changelog/add-shopper-collections-cart-hook b/plugins/woocommerce/changelog/add-shopper-collections-cart-hook
new file mode 100644
index 00000000000..bdb7a5c73b1
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collections-cart-hook
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Auto-inject the Shopper Collection block on the cart page when the shopper lists feature is enabled.
diff --git a/plugins/woocommerce/changelog/add-shopper-collections-feature-flag b/plugins/woocommerce/changelog/add-shopper-collections-feature-flag
new file mode 100644
index 00000000000..8e50071a012
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collections-feature-flag
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Gate shopper lists behind a new experimental flag.
\ No newline at end of file
diff --git a/plugins/woocommerce/changelog/add-shopper-collections-iapi-store b/plugins/woocommerce/changelog/add-shopper-collections-iapi-store
new file mode 100644
index 00000000000..c3832e63ebe
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collections-iapi-store
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Wire the Shopper Collection (Saved-for-later) block to the shopper-lists Store API via a private iAPI store, with server-side rendering of saved items, optimistic remove, and Move-to-cart for simple products.
diff --git a/plugins/woocommerce/changelog/add-shopper-collections-wishlists-base b/plugins/woocommerce/changelog/add-shopper-collections-wishlists-base
new file mode 100644
index 00000000000..f7a7efd9d11
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-shopper-collections-wishlists-base
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Shopper Collections: add experimental wishlist support.
diff --git a/plugins/woocommerce/changelog/add-wishlist-block b/plugins/woocommerce/changelog/add-wishlist-block
new file mode 100644
index 00000000000..95fa3752fed
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-wishlist-block
@@ -0,0 +1,5 @@
+Significance: minor
+Type: add
+Comment: Experimental Wishlist block, gated by the `product_wishlist` feature flag.
+
+Add the `woocommerce/wishlist` block: renders the shopper's wishlist via the shared `shopper-lists` Store API and iAPI store, composes the `ShopperListRenderer` shared row markup, and ships an "Add to cart" per-row action that adds the product to the cart and removes the row from the wishlist on confirmed success. The `/my-account/wishlist/` endpoint now renders this block instead of its placeholder.
diff --git a/plugins/woocommerce/changelog/feature-shopper-collection-empty-message-gating b/plugins/woocommerce/changelog/feature-shopper-collection-empty-message-gating
new file mode 100644
index 00000000000..e3f0c116d91
--- /dev/null
+++ b/plugins/woocommerce/changelog/feature-shopper-collection-empty-message-gating
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: hide the empty-state message until items have appeared in the list during the current page session.
diff --git a/plugins/woocommerce/changelog/fix-saved-for-later-tests-legacy-proxy-leak b/plugins/woocommerce/changelog/fix-saved-for-later-tests-legacy-proxy-leak
new file mode 100644
index 00000000000..f411bce7a14
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-saved-for-later-tests-legacy-proxy-leak
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Test-only change; no user-facing impact.
+
+Saved for Later tests: reset the LegacyProxy `is_cart()` mock in a `finally` block so it can't leak into later tests sharing the same process.
diff --git a/plugins/woocommerce/changelog/fix-shopper-collection-atomic-styles b/plugins/woocommerce/changelog/fix-shopper-collection-atomic-styles
new file mode 100644
index 00000000000..58cd1d542f4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-shopper-collection-atomic-styles
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: declare the atomic product-image, product-price and product-button stylesheets as style dependencies so their CSS is enqueued on the frontend whenever the block renders, instead of relying on duplicated/missing rules.
diff --git a/plugins/woocommerce/changelog/fix-shopper-collection-drop-editor-stylesheet b/plugins/woocommerce/changelog/fix-shopper-collection-drop-editor-stylesheet
new file mode 100644
index 00000000000..2d2e31a5c3a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-shopper-collection-drop-editor-stylesheet
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: drop the redundant editor stylesheet and rely on the frontend one for both the editor and the frontend.
diff --git a/plugins/woocommerce/changelog/fix-shopper-collection-hooked-block-flag b/plugins/woocommerce/changelog/fix-shopper-collection-hooked-block-flag
new file mode 100644
index 00000000000..ddacac9edea
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-shopper-collection-hooked-block-flag
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Set `cartPageHasSavedForLater` from the Shopper Collection block itself so the cart "Save for later" link shows when the block is auto-injected as a hooked block (which `has_block()` does not detect).
diff --git a/plugins/woocommerce/changelog/hide-shopper-collection-logged-out b/plugins/woocommerce/changelog/hide-shopper-collection-logged-out
new file mode 100644
index 00000000000..ce1d88c2212
--- /dev/null
+++ b/plugins/woocommerce/changelog/hide-shopper-collection-logged-out
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Shopper Collection: skip rendering and asset enqueue for logged-out shoppers.
diff --git a/plugins/woocommerce/changelog/refactor-extract-shopper-list-renderer b/plugins/woocommerce/changelog/refactor-extract-shopper-list-renderer
new file mode 100644
index 00000000000..18e221777a5
--- /dev/null
+++ b/plugins/woocommerce/changelog/refactor-extract-shopper-list-renderer
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Shopper lists: extract the shared row markup (image, name, price, remove badge, variation overlay) into a `ShopperListRenderer` helper and a `_shopper-lists/item` SCSS partial. Saved for Later now composes the helper; the same scaffold is what the upcoming Wishlist block will reuse.
diff --git a/plugins/woocommerce/changelog/refactor-shopper-list-schemas-take-objects b/plugins/woocommerce/changelog/refactor-shopper-list-schemas-take-objects
new file mode 100644
index 00000000000..85e9b311d87
--- /dev/null
+++ b/plugins/woocommerce/changelog/refactor-shopper-list-schemas-take-objects
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Pass ShopperList and ShopperListItem objects directly to their Store API schemas instead of round-tripping through to_array(); no JSON response shape change.
diff --git a/plugins/woocommerce/changelog/replace-shopper-collection-error-row-with-store-notice b/plugins/woocommerce/changelog/replace-shopper-collection-error-row-with-store-notice
new file mode 100644
index 00000000000..cdb9c3aef18
--- /dev/null
+++ b/plugins/woocommerce/changelog/replace-shopper-collection-error-row-with-store-notice
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: surface mutation errors via the store-notices banner instead of an in-list error row, and disable the affected row's controls while the request is in flight.
diff --git a/plugins/woocommerce/changelog/rsm-shopper-collections-add-storeapi-endpoints b/plugins/woocommerce/changelog/rsm-shopper-collections-add-storeapi-endpoints
new file mode 100644
index 00000000000..18fc33d8d5b
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-shopper-collections-add-storeapi-endpoints
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the foundation for shopper lists via new Store API endpoints under /wc/store/v1/shopper-lists. Lists are persisted per-user in usermeta.
diff --git a/plugins/woocommerce/changelog/rsm-shopper-collections-nonce-stopgap b/plugins/woocommerce/changelog/rsm-shopper-collections-nonce-stopgap
new file mode 100644
index 00000000000..d286adc4777
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-shopper-collections-nonce-stopgap
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Require a wc_store_api Nonce header on shopper-lists item writes.
diff --git a/plugins/woocommerce/changelog/tweak-shopper-collection-custom-grid b/plugins/woocommerce/changelog/tweak-shopper-collection-custom-grid
new file mode 100644
index 00000000000..a85ea8248f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/tweak-shopper-collection-custom-grid
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Shopper Collection: own the grid layout (custom `columnCount` attribute + `.columns-N` class) and ship Product-Collection-equivalent responsive behavior. Items now reflow to fewer columns on narrow viewports instead of shrinking uniformly.
diff --git a/plugins/woocommerce/client/blocks/.eslintrc.js b/plugins/woocommerce/client/blocks/.eslintrc.js
index fcf80dc83cb..499f9858a7d 100644
--- a/plugins/woocommerce/client/blocks/.eslintrc.js
+++ b/plugins/woocommerce/client/blocks/.eslintrc.js
@@ -134,6 +134,7 @@ const coreModules = [
 	'@woocommerce/stores/store-notices',
 	'@woocommerce/stores/woocommerce/cart',
 	'@woocommerce/stores/woocommerce/products',
+	'@woocommerce/stores/woocommerce/shopper-lists',
 	'@woocommerce/tracks',
 	'@woocommerce/data',
 	'@woocommerce/customer-effort-score',
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
index a05ab74801f..7e55fb004af 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
@@ -11,6 +11,7 @@ import {
 	useStoreCartItemQuantity,
 	useStoreEvents,
 	useStoreCart,
+	useSaveForLater,
 } from '@woocommerce/base-context/hooks';
 import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
 import {
@@ -19,8 +20,8 @@ import {
 } from '@woocommerce/blocks-checkout';
 import { forwardRef, useMemo } from '@wordpress/element';
 import type { CartItem } from '@woocommerce/types';
-import { objectHasProp, Currency } from '@woocommerce/types';
-import { getSetting } from '@woocommerce/settings';
+import { isBoolean, objectHasProp, Currency } from '@woocommerce/types';
+import { getSetting, getSettingWithCoercion } from '@woocommerce/settings';
 import { Icon, trash } from '@wordpress/icons';
 import { calculateSaleAmount } from '@woocommerce/base-utils';
 import { dinero, transformScale, toSnapshot, type Dinero } from 'dinero.js';
@@ -117,7 +118,31 @@ const CartLineItemRow: React.ForwardRefExoticComponent<

 		const { quantity, setItemQuantity, removeItem, isPendingDelete } =
 			useStoreCartItemQuantity( lineItem );
+		const { saveForLater, isSaving: isSavingForLater } = useSaveForLater();
 		const { dispatchStoreEvent } = useStoreEvents();
+		const isUserLoggedIn = !! getSetting< number >( 'currentUserId', 0 );
+		const isSaveForLaterFeatureEnabled = getSettingWithCoercion(
+			'experimentalCartSaveForLater',
+			false,
+			isBoolean
+		);
+		const cartPageHasSavedForLater = getSettingWithCoercion(
+			'cartPageHasSavedForLater',
+			false,
+			isBoolean
+		);
+		// Three signals, each catching a distinct failure mode.
+		// Disabling the `cart_save_for_later` feature unregisters the
+		// saved-for-later block but leaves any prior insertion in the
+		// cart page's post content (the editor renders it as an
+		// "unsupported block" notice) — so presence alone could render
+		// this link with no working destination. Inversely, the feature
+		// can be enabled on cart pages that never inserted the block.
+		// And the REST endpoints behind the click are auth-only.
+		const showSaveForLater =
+			isUserLoggedIn &&
+			isSaveForLaterFeatureEnabled &&
+			cartPageHasSavedForLater;

 		// Prepare props to pass to the applyCheckoutFilter filter.
 		// We need to pluck out receiveCart.
@@ -339,6 +364,59 @@ const CartLineItemRow: React.ForwardRefExoticComponent<
 								</button>
 							) }
 						</div>
+						{ showSaveForLater && (
+							<div className="wc-block-cart-item__save-for-later">
+								<button
+									type="button"
+									className="wc-block-cart-item__save-for-later-link"
+									onClick={ async () => {
+										const saved = await saveForLater(
+											lineItem.key
+										);
+										if ( ! saved ) {
+											return;
+										}
+										// removeItem surfaces its own errors
+										// via processErrorResponse; we still
+										// fire the analytics event and a11y
+										// announcement to mirror the regular
+										// remove flow.
+										await removeItem();
+										// TODO: consider a dedicated
+										// 'cart-save-for-later' store event so
+										// analytics can distinguish a save
+										// from a plain remove.
+										dispatchStoreEvent(
+											'cart-remove-item',
+											{
+												product: lineItem,
+												quantity,
+											}
+										);
+										speak(
+											sprintf(
+												/* translators: %s refers to the item name. */
+												__(
+													'%s has been saved for later and removed from your cart.',
+													'woocommerce'
+												),
+												name
+											)
+										);
+									} }
+									disabled={
+										isPendingDelete || isSavingForLater
+									}
+								>
+									{ isSavingForLater
+										? __( 'Saving…', 'woocommerce' )
+										: __(
+												'Save for later',
+												'woocommerce'
+										  ) }
+								</button>
+							</div>
+						) }
 					</div>
 				</td>
 				<td className="wc-block-cart-item__total">
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/style.scss
index a125901d08a..6bf603630d0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/style.scss
@@ -96,6 +96,21 @@ table.wc-block-cart-items {
 				}
 			}
 		}
+		.wc-block-cart-item__save-for-later {
+			.wc-block-cart-item__save-for-later-link {
+				@include link-button();
+				@include hover-effect();
+				@include font-small-locked;
+
+				text-transform: none;
+				white-space: nowrap;
+
+				&:disabled {
+					cursor: default;
+					opacity: 0.5;
+				}
+			}
+		}
 		.wc-block-components-product-name {
 			@include font-small-locked;
 			display: block;
@@ -119,6 +134,10 @@ table.wc-block-cart-items {
 			transition: opacity 200ms ease;
 		}
 	}
+
+	&.wc-block-mini-cart-items .wc-block-cart-item__save-for-later {
+		display: none;
+	}
 }

 @mixin cart-items-table-styles() {
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx
new file mode 100644
index 00000000000..2d9bb1e32f5
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx
@@ -0,0 +1,247 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import type { CartItem } from '@woocommerce/types';
+import { getSetting, getSettingWithCoercion } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import CartLineItemRow from '../cart-line-item-row';
+
+jest.mock( '@woocommerce/settings', () => ( {
+	...jest.requireActual( '@woocommerce/settings' ),
+	// Default implementations return the provided defaultValue so module-load
+	// time calls (e.g. `getSetting( 'wcBlocksConfig', { pluginUrl: '', ... } )`
+	// in the settings constants module) still receive a usable shape. Per-test
+	// overrides via `setSettings` below replace these implementations.
+	getSetting: jest.fn(
+		( _key: string, defaultValue: unknown ) => defaultValue
+	),
+	getSettingWithCoercion: jest.fn(
+		( _key: string, defaultValue: unknown ) => defaultValue
+	),
+} ) );
+
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+	...jest.requireActual( '@woocommerce/base-context/hooks' ),
+	useStoreCartItemQuantity: jest.fn( () => ( {
+		quantity: 1,
+		setItemQuantity: jest.fn(),
+		removeItem: jest.fn(),
+		isPendingDelete: false,
+	} ) ),
+	useStoreEvents: jest.fn( () => ( {
+		dispatchStoreEvent: jest.fn(),
+	} ) ),
+	useStoreCart: jest.fn( () => ( {
+		receiveCart: jest.fn(),
+		cartItems: [],
+	} ) ),
+	useSaveForLater: jest.fn( () => ( {
+		saveForLater: jest.fn(),
+		isSaving: false,
+	} ) ),
+} ) );
+
+jest.mock( '@woocommerce/blocks-checkout', () => ( {
+	...jest.requireActual( '@woocommerce/blocks-checkout' ),
+	applyCheckoutFilter: jest.fn( ( { defaultValue } ) => defaultValue ),
+	productPriceValidation: jest.fn(),
+} ) );
+
+const mockGetSetting = getSetting as jest.Mock;
+const mockGetSettingWithCoercion = getSettingWithCoercion as jest.Mock;
+
+/**
+ * Minimal CartItem shape — only the fields the row actually reads. Using an
+ * inline fixture instead of `previewCart` keeps this test free of the
+ * preview module's transitive imports (which expect `wcBlocksConfig` to be
+ * populated at module-load time).
+ */
+const buildLineItem = (): CartItem =>
+	( {
+		key: 'test-key',
+		id: 1,
+		type: 'simple',
+		quantity: 1,
+		catalog_visibility: 'visible',
+		name: 'Test product',
+		short_description: '',
+		description: '',
+		sku: 'test-sku',
+		permalink: 'https://example.org',
+		low_stock_remaining: null,
+		backorders_allowed: false,
+		show_backorder_badge: false,
+		sold_individually: false,
+		quantity_limits: {
+			minimum: 1,
+			maximum: 99,
+			multiple_of: 1,
+			editable: true,
+		},
+		images: [],
+		variation: [],
+		item_data: [],
+		prices: {
+			currency_code: 'USD',
+			currency_minor_unit: 2,
+			currency_symbol: '$',
+			currency_prefix: '$',
+			currency_suffix: '',
+			currency_decimal_separator: '.',
+			currency_thousand_separator: ',',
+			price: '1000',
+			regular_price: '1000',
+			sale_price: '1000',
+			price_range: null,
+			raw_prices: {
+				precision: 6,
+				price: '10000000',
+				regular_price: '10000000',
+				sale_price: '10000000',
+			},
+		},
+		totals: {
+			currency_code: 'USD',
+			currency_minor_unit: 2,
+			currency_symbol: '$',
+			currency_prefix: '$',
+			currency_suffix: '',
+			currency_decimal_separator: '.',
+			currency_thousand_separator: ',',
+			line_subtotal: '1000',
+			line_subtotal_tax: '0',
+			line_total: '1000',
+			line_total_tax: '0',
+		},
+		extensions: {},
+	} as unknown as CartItem );
+
+/**
+ * Configure `getSetting` and `getSettingWithCoercion` mocks for the three
+ * signals the cart row reads when deciding whether to render the
+ * "Save for later" button. Any setting not explicitly provided falls back to
+ * the default supplied by the caller (same behaviour as the production
+ * helpers).
+ */
+const setSettings = ( {
+	currentUserId = 0,
+	experimentalCartSaveForLater,
+	cartPageHasSavedForLater,
+}: {
+	currentUserId?: number;
+	experimentalCartSaveForLater?: boolean;
+	cartPageHasSavedForLater?: boolean;
+} ) => {
+	mockGetSetting.mockImplementation(
+		( key: string, defaultValue: unknown ) => {
+			if ( key === 'currentUserId' ) {
+				return currentUserId;
+			}
+			return defaultValue;
+		}
+	);
+	mockGetSettingWithCoercion.mockImplementation(
+		( key: string, defaultValue: unknown ) => {
+			if (
+				key === 'experimentalCartSaveForLater' &&
+				experimentalCartSaveForLater !== undefined
+			) {
+				return experimentalCartSaveForLater;
+			}
+			if (
+				key === 'cartPageHasSavedForLater' &&
+				cartPageHasSavedForLater !== undefined
+			) {
+				return cartPageHasSavedForLater;
+			}
+			return defaultValue;
+		}
+	);
+};
+
+const renderRow = () => {
+	// Wrap in a table so the row's <td>s render in a valid context.
+	return render(
+		<table>
+			<tbody>
+				<CartLineItemRow lineItem={ buildLineItem() } />
+			</tbody>
+		</table>
+	);
+};
+
+describe( 'CartLineItemRow — Save for later visibility', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'shows the Save for later button when user is logged in, feature is enabled, and the page has a saved-for-later block', () => {
+		setSettings( {
+			currentUserId: 42,
+			experimentalCartSaveForLater: true,
+			cartPageHasSavedForLater: true,
+		} );
+
+		renderRow();
+
+		expect(
+			screen.getByRole( 'button', { name: /save for later/i } )
+		).toBeInTheDocument();
+	} );
+
+	it( 'hides the Save for later button when the user is logged out', () => {
+		setSettings( {
+			currentUserId: 0,
+			experimentalCartSaveForLater: true,
+			cartPageHasSavedForLater: true,
+		} );
+
+		renderRow();
+
+		expect(
+			screen.queryByRole( 'button', { name: /save for later/i } )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'hides the Save for later button when the page has no saved-for-later block', () => {
+		setSettings( {
+			currentUserId: 42,
+			experimentalCartSaveForLater: true,
+			cartPageHasSavedForLater: false,
+		} );
+
+		renderRow();
+
+		expect(
+			screen.queryByRole( 'button', { name: /save for later/i } )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'hides the Save for later button when the feature flag is disabled', () => {
+		setSettings( {
+			currentUserId: 42,
+			experimentalCartSaveForLater: false,
+			cartPageHasSavedForLater: true,
+		} );
+
+		renderRow();
+
+		expect(
+			screen.queryByRole( 'button', { name: /save for later/i } )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'hides the Save for later button when none of the signals are set (defaults)', () => {
+		setSettings( {} );
+
+		renderRow();
+
+		expect(
+			screen.queryByRole( 'button', { name: /save for later/i } )
+		).not.toBeInTheDocument();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/index.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/index.ts
index 8f510164b6b..4f125fd2e71 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/index.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/index.ts
@@ -1,3 +1,4 @@
 export * from './use-store-cart';
 export * from './use-store-cart-coupons';
 export * from './use-store-cart-item-quantity';
+export * from './use-save-for-later';
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts
new file mode 100644
index 00000000000..54501e584bf
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts
@@ -0,0 +1,64 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { dispatch, useDispatch } from '@wordpress/data';
+import { store as noticesStore } from '@wordpress/notices';
+import { cartStore } from '@woocommerce/block-data';
+
+/**
+ * Save a cart line item to the saved-for-later shopper list.
+ *
+ * Dispatches the wp.data cart store's `saveForLater` thunk, which POSTs the
+ * item and emits a `wc-blocks_store_sync_required` event. A Saved for Later
+ * block on the same page picks up that event in its iAPI store and applies
+ * the new row reactively.
+ *
+ * Resolves to `true` on success so the caller can chain the cart removal;
+ * on failure surfaces an error notice in the cart context and resolves to
+ * `false`. The cart removal is the caller's responsibility — keep the two
+ * awaits separate so save and remove errors stay distinct.
+ */
+export const useSaveForLater = (): {
+	isSaving: boolean;
+	saveForLater: ( cartItemKey: string ) => Promise< boolean >;
+} => {
+	const [ isSaving, setIsSaving ] = useState( false );
+	const { saveForLater: dispatchSaveForLater } = useDispatch( cartStore );
+
+	const saveForLater = useCallback(
+		async ( cartItemKey: string ): Promise< boolean > => {
+			if ( ! cartItemKey || isSaving ) {
+				return false;
+			}
+			setIsSaving( true );
+			try {
+				await dispatchSaveForLater( cartItemKey );
+				return true;
+			} catch ( error ) {
+				const message =
+					error &&
+					typeof error === 'object' &&
+					'message' in error &&
+					typeof ( error as { message: unknown } ).message ===
+						'string'
+						? ( error as { message: string } ).message
+						: __(
+								'There was a problem saving this item for later.',
+								'woocommerce'
+						  );
+				dispatch( noticesStore ).createNotice( 'error', message, {
+					context: 'wc/cart',
+					isDismissible: true,
+				} );
+				return false;
+			} finally {
+				setIsSaving( false );
+			}
+		},
+		[ isSaving, dispatchSaveForLater ]
+	);
+
+	return { isSaving, saveForLater };
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts
new file mode 100644
index 00000000000..523b1ca8944
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts
@@ -0,0 +1,347 @@
+/**
+ * External dependencies
+ */
+import { store } from '@wordpress/interactivity';
+import type { AsyncAction, TypeYield } from '@wordpress/interactivity';
+import type { CurrencyResponse } from '@woocommerce/types';
+import type { Store as StoreNotices } from '@woocommerce/stores/store-notices';
+
+/**
+ * Mirror of `Automattic\WooCommerce\StoreApi\Schemas\V1\ShopperListItemSchema::get_properties()`.
+ *
+ * Keep this in sync with the schema. State here must not include any UI-derived
+ * fields — display values belong in block-private stores or PHP SSR.
+ * TO DO: decide where UI-derived state lives
+ */
+export type ShopperListItemImage = {
+	id: number;
+	src: string;
+	thumbnail: string;
+	srcset: string;
+	sizes: string;
+	name: string;
+	alt: string;
+	thumbnail_srcset: string;
+	thumbnail_sizes: string;
+};
+
+export type ShopperListItemVariation = {
+	raw_attribute: string;
+	attribute: string;
+	value: string;
+};
+
+export type ShopperListItemPrices = CurrencyResponse & {
+	price: string;
+	regular_price: string;
+	sale_price: string;
+};
+
+export type RawShopperListItem = {
+	key: string;
+	id: number;
+	product_id: number;
+	variation_id: number;
+	quantity: number;
+	is_live: boolean;
+	is_purchasable: boolean;
+	name: string;
+	permalink: string | null;
+	images: ShopperListItemImage[];
+	variation: ShopperListItemVariation[];
+	prices: ShopperListItemPrices | null;
+	price_html: string;
+	image_html: string;
+	date_added_gmt: string;
+};
+
+export type ShopperListState = {
+	items: RawShopperListItem[];
+	isLoading: boolean;
+};
+
+export type AddItemPayload = {
+	product_id?: number;
+	cart_item_key?: string;
+	variation?: Array< { attribute: string; value: string } >;
+	quantity?: number;
+};
+
+export type Store = {
+	state: {
+		restUrl: string;
+		// TODO: revisit nonce handling when we look at authentication for
+		// the shopper-lists routes. Today PHP seeds this via
+		// `wp_create_nonce( 'wc_store_api' )` and we refresh it from
+		// response headers (see restRequest below). Likely changes once
+		// the routes start enforcing nonces server-side: align with the
+		// cart store's bootstrap-from-response-header pattern, share the
+		// cart's `state.nonce` instead of duplicating, or move to a
+		// caching-friendlier transport.
+		nonce: string;
+		lists: Record< string, ShopperListState >;
+	};
+	actions: {
+		loadList: ( slug: string ) => Promise< void >;
+		addItem: ( slug: string, payload: AddItemPayload ) => Promise< void >;
+		removeItem: ( slug: string, key: string ) => Promise< void >;
+		showNoticeError: ( error: Error ) => Promise< void >;
+	};
+};
+
+// Stores are locked to prevent 3PD usage until the API is stable.
+const universalLock =
+	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const isShopperListItem = ( value: unknown ): value is RawShopperListItem =>
+	!! value &&
+	typeof value === 'object' &&
+	typeof ( value as { key?: unknown } ).key === 'string';
+
+const ensureListState = (
+	state: Store[ 'state' ],
+	slug: string
+): ShopperListState => {
+	let list = state.lists[ slug ];
+	if ( ! list ) {
+		list = { items: [], isLoading: false };
+		state.lists[ slug ] = list;
+	}
+	return list;
+};
+
+/**
+ * Send a Store API request following the cart store's auth shape:
+ * Nonce header, `wc_store_api` action on the server side, cookie auth via
+ * `credentials: 'include'`, and `cache: 'no-store'` so user-specific data is
+ * never cached.
+ *
+ * The starter nonce is seeded by PHP via `wp_interactivity_state` and
+ * refreshed from the `Nonce` response header on every subsequent request,
+ * so the server-side enforcement (landing in a follow-up PR) can be
+ * flipped on without rewriting the client.
+ */
+async function restRequest< T >(
+	state: Store[ 'state' ],
+	path: string,
+	init: RequestInit = {}
+): Promise< T | null > {
+	const headers = new Headers( init.headers );
+	headers.set( 'Content-Type', 'application/json' );
+	if ( state.nonce ) {
+		headers.set( 'Nonce', state.nonce );
+	}
+
+	const response = await fetch( `${ state.restUrl }${ path }`, {
+		...init,
+		headers,
+		cache: 'no-store',
+		credentials: 'include',
+	} );
+
+	const nextNonce = response.headers.get( 'Nonce' );
+	if ( nextNonce ) {
+		state.nonce = nextNonce;
+	}
+
+	if ( response.status === 204 ) {
+		return null;
+	}
+
+	const text = await response.text();
+	const contentType = response.headers.get( 'Content-Type' ) || '';
+	const json =
+		text && contentType.includes( 'json' ) ? JSON.parse( text ) : null;
+
+	if ( ! response.ok ) {
+		const message =
+			( json && typeof json === 'object' && 'message' in json
+				? String( ( json as { message: unknown } ).message )
+				: '' ) ||
+			response.statusText ||
+			'Request failed.';
+		throw new Error( message );
+	}
+
+	return json as T | null;
+}
+
+// Do NOT supply `nonce` / `restUrl` defaults here. iAPI's deep-merge has the
+// JS-supplied state win over the existing (PHP-seeded) state for primitives,
+// so an empty-string default would clobber the values seeded server-side via
+// `wp_interactivity_state`. State for those fields comes purely from PHP. Same
+// reason the cart store doesn't ship state defaults — see cart.ts.
+const { state, actions } = store< Store >(
+	'woocommerce/shopper-lists',
+	{
+		actions: {
+			*loadList( slug: string ): AsyncAction< void > {
+				const list = ensureListState( state, slug );
+				list.isLoading = true;
+
+				try {
+					const response = ( yield restRequest<
+						RawShopperListItem[]
+					>(
+						state,
+						`wc/store/v1/shopper-lists/${ encodeURIComponent(
+							slug
+						) }/items`,
+						{ method: 'GET' }
+					) ) as TypeYield<
+						typeof restRequest< RawShopperListItem[] >
+					>;
+
+					if ( ! Array.isArray( response ) ) {
+						throw new Error( 'Invalid shopper list response.' );
+					}
+
+					const items = response.filter( isShopperListItem );
+
+					// TODO: track in-flight mutation count and skip applying
+					// load results when mutations are pending, so a slow
+					// loadList cannot clobber a fresh add/remove.
+					list.items = items;
+				} catch ( error ) {
+					// No user trigger to attach a banner to; log for ops.
+					// eslint-disable-next-line no-console
+					console.error( error );
+				} finally {
+					list.isLoading = false;
+				}
+			},
+
+			*addItem(
+				slug: string,
+				payload: AddItemPayload
+			): AsyncAction< void > {
+				const list = ensureListState( state, slug );
+
+				try {
+					const item = ( yield restRequest< RawShopperListItem >(
+						state,
+						`wc/store/v1/shopper-lists/${ encodeURIComponent(
+							slug
+						) }/items`,
+						{
+							method: 'POST',
+							body: JSON.stringify( payload ),
+						}
+					) ) as TypeYield<
+						typeof restRequest< RawShopperListItem >
+					>;
+
+					if ( ! isShopperListItem( item ) ) {
+						throw new Error(
+							'Invalid shopper list item response.'
+						);
+					}
+
+					// Merge the returned item by key — replace if present,
+					// append otherwise. Re-saving the same product POSTs
+					// twice and the server merges quantity, so we mirror
+					// that behaviour locally.
+					const existingIndex = list.items.findIndex(
+						( i ) => i.key === item.key
+					);
+					if ( existingIndex >= 0 ) {
+						list.items[ existingIndex ] = item;
+					} else {
+						list.items.push( item );
+					}
+				} catch ( error ) {
+					actions.showNoticeError( error as Error );
+				}
+			},
+
+			*removeItem( slug: string, key: string ): AsyncAction< void > {
+				const list = state.lists[ slug ];
+				if ( ! list ) {
+					return;
+				}
+
+				if ( list.items.findIndex( ( i ) => i.key === key ) < 0 ) {
+					return;
+				}
+
+				// Pessimistic remove: leave the row in place until the
+				// server confirms, so failures don't flash. Buttons are
+				// disabled meanwhile via the block's `pendingKeys`.
+				try {
+					yield restRequest(
+						state,
+						`wc/store/v1/shopper-lists/${ encodeURIComponent(
+							slug
+						) }/items/${ encodeURIComponent( key ) }`,
+						{ method: 'DELETE' }
+					);
+				} catch ( error ) {
+					actions.showNoticeError( error as Error );
+					return;
+				}
+
+				// Re-find — the list may have mutated during the await.
+				const removedIndex = list.items.findIndex(
+					( i ) => i.key === key
+				);
+				if ( removedIndex >= 0 ) {
+					list.items.splice( removedIndex, 1 );
+				}
+			},
+
+			// Mirrors `cart.ts::showNoticeError`.
+			*showNoticeError( error: Error ): AsyncAction< void > {
+				yield import( '@woocommerce/stores/store-notices' );
+				const { actions: noticeActions } = store< StoreNotices >(
+					'woocommerce/store-notices',
+					{},
+					{ lock: universalLock }
+				);
+
+				noticeActions.addNotice( {
+					notice: error.message,
+					type: 'error',
+					dismissible: true,
+				} );
+
+				// eslint-disable-next-line no-console
+				console.error( error );
+			},
+		},
+	},
+	{ lock: universalLock }
+);
+
+// Listen for shopper-list item additions emitted from the wp.data side (e.g.
+// the cart store's saveForLater thunk). Mirrors the cart's iAPI → wp.data
+// sync direction, which also ships a payload (`from_iAPI` carries
+// `quantityChanges`). The event carries the saved item directly so we can
+// splice it in without an extra GET — keeps the merge ordering deterministic
+// and avoids the loadList-vs-mutation race the iAPI store's loadList still
+// has a TODO about.
+//
+// Keeps the discriminator + payload contract in sync with
+// `assets/js/data/cart/thunks.ts::saveForLater`.
+window.addEventListener( 'wc-blocks_store_sync_required', ( event: Event ) => {
+	const detail = ( event as CustomEvent ).detail as
+		| { type?: string; slug?: string; item?: RawShopperListItem }
+		| undefined;
+	if ( detail?.type !== 'shopper-list-item-added' ) {
+		return;
+	}
+	if (
+		typeof detail.slug !== 'string' ||
+		detail.slug.trim().length === 0 ||
+		! isShopperListItem( detail.item )
+	) {
+		return;
+	}
+	const list = ensureListState( state, detail.slug );
+	const item = detail.item;
+	const existingIndex = list.items.findIndex( ( i ) => i.key === item.key );
+	if ( existingIndex >= 0 ) {
+		list.items[ existingIndex ] = item;
+	} else {
+		list.items.push( item );
+	}
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/block.json
new file mode 100644
index 00000000000..da8995cce57
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/block.json
@@ -0,0 +1,33 @@
+{
+	"$schema": "https://schemas.wp.org/trunk/block.json",
+	"apiVersion": 3,
+	"name": "woocommerce/add-to-wishlist-button",
+	"version": "1.0.0",
+	"title": "Add to Wishlist Button",
+	"description": "Lets shoppers add the current product to their wishlist from the single-product page.",
+	"category": "woocommerce",
+	"keywords": [ "WooCommerce", "Wishlist" ],
+	"textdomain": "woocommerce",
+	"usesContext": [ "postId" ],
+	"ancestor": [ "woocommerce/add-to-cart-with-options" ],
+	"supports": {
+		"interactivity": true,
+		"html": false,
+		"reusable": false,
+		"multiple": false,
+		"spacing": {
+			"margin": true,
+			"padding": true
+		},
+		"typography": {
+			"fontSize": true,
+			"lineHeight": true
+		},
+		"color": {
+			"text": true,
+			"background": true
+		}
+	},
+	"viewScriptModule": "woocommerce/add-to-wishlist-button",
+	"style": [ "file:../woocommerce/add-to-wishlist-button-style.css" ]
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/edit.tsx
new file mode 100644
index 00000000000..eae7d961573
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/edit.tsx
@@ -0,0 +1,31 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useBlockProps } from '@wordpress/block-editor';
+import { Icon, starEmpty } from '@wordpress/icons';
+
+const Edit = (): JSX.Element => {
+	const blockProps = useBlockProps( {
+		className: 'wc-block-add-to-wishlist-button',
+	} );
+
+	return (
+		<div { ...blockProps }>
+			<button
+				type="button"
+				className="wc-block-add-to-wishlist-button__toggle"
+				disabled
+			>
+				<span className="wc-block-add-to-wishlist-button__icon wc-block-add-to-wishlist-button__icon--empty">
+					<Icon icon={ starEmpty } size={ 24 } />
+				</span>
+				<span className="wc-block-add-to-wishlist-button__label">
+					{ __( 'Add to wishlist', 'woocommerce' ) }
+				</span>
+			</button>
+		</div>
+	);
+};
+
+export default Edit;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts
new file mode 100644
index 00000000000..1c639be4d9a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts
@@ -0,0 +1,227 @@
+/**
+ * External dependencies
+ */
+import {
+	getConfig,
+	getContext,
+	store,
+	type AsyncAction,
+} from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/products';
+import '@woocommerce/stores/woocommerce/shopper-lists';
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
+import type { SelectedAttributes } from '@woocommerce/stores/woocommerce/cart';
+import type {
+	RawShopperListItem,
+	Store as ShopperListsStore,
+} from '@woocommerce/stores/woocommerce/shopper-lists';
+
+/**
+ * Internal dependencies
+ */
+import { matchVariationItem } from './match-variation-item';
+
+const universalLock =
+	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const LIST_SLUG = 'wishlist';
+
+type ButtonConfig = {
+	addLabel: string;
+	savedLabel: string;
+	selectOptionsLabel: string;
+};
+
+type BlockContext = {
+	productId: number;
+	isVariableType: boolean;
+	// Mid-click flag, gated per-block so the button can be disabled while
+	// the request is in flight. Single-instance block, no `pendingKeys`
+	// map needed (Wishlist/SFL use one because they're per-row).
+	isPending: boolean;
+};
+
+// The narrow slice of ATCWO's iAPI context this block consumes. Reuses
+// `SelectedAttributes` from the cart store — the same type ATCWO uses for
+// its own `selectedAttributes` context field — so any shape change there
+// (e.g. adding a `taxonomy` field) flows through here automatically.
+type ATCWOContext = {
+	selectedAttributes: SelectedAttributes[];
+};
+
+type BlockStore = {
+	state: {
+		effectiveProductId: number;
+		currentItem: RawShopperListItem | null;
+		isInWishlist: boolean;
+		isDisabled: boolean;
+		currentLabel: string;
+	};
+	actions: {
+		onClickToggle: () => Generator< unknown, void >;
+	};
+};
+
+const { state: productsState } = store< ProductsStore >(
+	'woocommerce/products',
+	{},
+	{ lock: universalLock }
+);
+
+const { state: shopperListsState, actions: shopperListsActions } =
+	store< ShopperListsStore >(
+		'woocommerce/shopper-lists',
+		{},
+		{ lock: universalLock }
+	);
+
+const { state } = store< BlockStore >(
+	'woocommerce/add-to-wishlist-button',
+	{
+		state: {
+			// For variable products, the effective product is the selected
+			// variation — resolved through the products store's
+			// `productInContext` derived getter, which already encapsulates
+			// "variation if one is selected, otherwise the parent." Returns
+			// 0 when the current resolution is still the variable parent
+			// (i.e. the shopper hasn't picked attributes yet), which
+			// `isDisabled` reads as "not yet selectable."
+			get effectiveProductId(): number {
+				const product = productsState.productInContext;
+				if ( ! product ) {
+					return 0;
+				}
+				const context = getContext< BlockContext >();
+				if ( context.isVariableType && product.type === 'variable' ) {
+					return 0;
+				}
+				return product.id;
+			},
+
+			get currentItem(): RawShopperListItem | null {
+				const id = state.effectiveProductId;
+				if ( ! id ) {
+					return null;
+				}
+				const list = shopperListsState.lists[ LIST_SLUG ];
+				if ( ! list ) {
+					return null;
+				}
+				const context = getContext< BlockContext >();
+				// For non-variable products, id alone uniquely identifies
+				// the wishlist row. For variable products with "any"
+				// attribute slots, several attribute combinations can map
+				// to the same variation product, so we additionally
+				// disambiguate by the shopper's picked attributes — see
+				// `matchVariationItem` for details.
+				if ( ! context.isVariableType ) {
+					return (
+						list.items.find( ( item ) => item.id === id ) ?? null
+					);
+				}
+				const addToCartContext = getContext< ATCWOContext >(
+					'woocommerce/add-to-cart-with-options'
+				);
+				const selected = addToCartContext?.selectedAttributes ?? [];
+				return (
+					list.items.find( ( item ) =>
+						matchVariationItem( item, id, selected )
+					) ?? null
+				);
+			},
+
+			get isInWishlist(): boolean {
+				return state.currentItem !== null;
+			},
+
+			get isDisabled(): boolean {
+				const context = getContext< BlockContext >();
+				if ( context.isPending ) {
+					return true;
+				}
+				return ! state.effectiveProductId;
+			},
+
+			get currentLabel(): string {
+				const { addLabel, savedLabel, selectOptionsLabel } = getConfig(
+					'woocommerce/add-to-wishlist-button'
+				) as ButtonConfig;
+
+				if ( ! state.effectiveProductId ) {
+					return selectOptionsLabel;
+				}
+				return state.isInWishlist ? savedLabel : addLabel;
+			},
+		},
+
+		actions: {
+			*onClickToggle(): AsyncAction< void > {
+				const context = getContext< BlockContext >();
+				if ( context.isPending ) {
+					return;
+				}
+				const id = state.effectiveProductId;
+				if ( ! id ) {
+					return;
+				}
+
+				const existing = state.currentItem;
+				context.isPending = true;
+				try {
+					if ( existing ) {
+						yield shopperListsActions.removeItem(
+							LIST_SLUG,
+							existing.key
+						);
+					} else {
+						// We inherit ATCWO's iAPI context because this block
+						// is an inner block of ATCWO (enforced by
+						// `ancestor` in `block.json`). That lets us read
+						// the shopper-picked attributes — needed for
+						// variations with "any" slots, where the server
+						// can't resolve the line item without them.
+						//
+						// ATCWO stores them by display label ("Color"), but
+						// the shopper-lists route expects taxonomy slugs
+						// ("pa_color"). Map via the parent product's
+						// `attributes` table; fall back to the raw name for
+						// non-taxonomy custom attributes.
+						//
+						// TODO: drop this mapping once ATCWO exposes the
+						// taxonomy on `selectedAttributes` directly.
+						const addToCartContext = getContext< ATCWOContext >(
+							'woocommerce/add-to-cart-with-options'
+						);
+						const parent = productsState.mainProductInContext;
+						const attrMap = new Map< string, string >();
+						parent?.attributes?.forEach(
+							( a: {
+								name?: string;
+								taxonomy?: string | null;
+							} ) => {
+								if ( a.name ) {
+									attrMap.set( a.name, a.taxonomy || a.name );
+								}
+							}
+						);
+						const variation =
+							addToCartContext?.selectedAttributes?.map(
+								( { attribute, value } ) => ( {
+									attribute:
+										attrMap.get( attribute ) ?? attribute,
+									value,
+								} )
+							) ?? [];
+						yield shopperListsActions.addItem( LIST_SLUG, {
+							product_id: id,
+							...( variation.length && { variation } ),
+						} );
+					}
+				} finally {
+					context.isPending = false;
+				}
+			},
+		},
+	},
+	{ lock: universalLock }
+);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/index.tsx
new file mode 100644
index 00000000000..21f24c7656a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/index.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { Icon, starEmpty } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import Save from './save';
+import './style.scss';
+
+registerBlockType( metadata, {
+	icon: {
+		src: (
+			<Icon
+				icon={ starEmpty }
+				className="wc-block-editor-components-block-icon"
+			/>
+		),
+	},
+	edit: Edit,
+	save: Save,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts
new file mode 100644
index 00000000000..78369a4b093
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts
@@ -0,0 +1,61 @@
+/**
+ * Items in the wishlist whose variation values we can compare against the
+ * shopper's currently picked attributes. Narrowed from `RawShopperListItem`
+ * so this helper stays pure (no iAPI deps) and unit-testable in isolation.
+ */
+type MatchableItem = {
+	id: number;
+	variation?: Array< {
+		attribute: string;
+		value: string;
+	} > | null;
+};
+
+/**
+ * The shopper's currently picked attributes — same shape as ATCWO's
+ * `selectedAttributes` context entries (also re-exported from the cart store
+ * as `SelectedAttributes`).
+ */
+type SelectedPair = {
+	attribute: string;
+	value: string;
+};
+
+/**
+ * Decide whether a wishlist item represents the exact variation + attribute
+ * combination the shopper has currently picked. For fully-constrained
+ * variations a single `id` match is enough, but for "any" attribute slots
+ * several attribute combinations can share the same variation product (and
+ * therefore the same `item.id`), so we additionally compare the attribute
+ * sets. The asymmetric `value` comparison is case-insensitive because the
+ * Store API returns each entry's `value` as the term display name ("Red")
+ * while ATCWO carries the term slug ("red") in `selectedAttributes`.
+ *
+ * Edge cases out of scope here (would need a slug→name lookup via the parent
+ * product's `terms`): slugs whose shape differs from the display name beyond
+ * capitalization, e.g. `"bright-red"` vs `"Bright Red"`.
+ *
+ * @param item     Wishlist item from the shopper-lists store.
+ * @param id       Effective product/variation id we're comparing against.
+ * @param selected Shopper's picked attribute/value pairs.
+ */
+export function matchVariationItem(
+	item: MatchableItem,
+	id: number,
+	selected: SelectedPair[]
+): boolean {
+	if ( item.id !== id ) {
+		return false;
+	}
+	const stored = item.variation ?? [];
+	if ( stored.length !== selected.length ) {
+		return false;
+	}
+	return selected.every( ( sel ) =>
+		stored.some(
+			( v ) =>
+				v.attribute === sel.attribute &&
+				v.value.toLowerCase() === sel.value.toLowerCase()
+		)
+	);
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx
new file mode 100644
index 00000000000..cdbc6befb56
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx
@@ -0,0 +1,7 @@
+/**
+ * The block is rendered server-side and has no inner blocks, so there's
+ * nothing to serialise to `post_content`.
+ */
+const Save = (): null => null;
+
+export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/style.scss
new file mode 100644
index 00000000000..3e662b8502c
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/style.scss
@@ -0,0 +1,42 @@
+// Frontend stylesheet for the Add to Wishlist button block. Mirrors the
+// link-button cue the cart's per-row Save-for-later button uses — a subtle
+// inline button that sits under the primary Add to Cart CTA.
+
+.wc-block-add-to-wishlist-button {
+	margin-top: 0.5em;
+
+	.wc-block-add-to-wishlist-button__toggle {
+		align-items: center;
+		background: none;
+		border: 0;
+		color: inherit;
+		cursor: pointer;
+		display: inline-flex;
+		font: inherit;
+		gap: 0.25em;
+		padding: 0;
+		text-decoration: none;
+
+		&:hover:not(:disabled) {
+			text-decoration: underline;
+		}
+
+		&:disabled {
+			cursor: default;
+			opacity: 0.5;
+		}
+	}
+
+	.wc-block-add-to-wishlist-button__icon {
+		display: inline-flex;
+
+		&[hidden] {
+			display: none;
+		}
+
+		svg {
+			height: 1.25em;
+			width: 1.25em;
+		}
+	}
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/test/match-variation-item.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/test/match-variation-item.ts
new file mode 100644
index 00000000000..16b44907bba
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/test/match-variation-item.ts
@@ -0,0 +1,90 @@
+/**
+ * Internal dependencies
+ */
+import { matchVariationItem } from '../match-variation-item';
+
+describe( 'matchVariationItem', () => {
+	const redLarge = {
+		id: 42,
+		variation: [
+			{ attribute: 'Color', value: 'Red' },
+			{ attribute: 'Size', value: 'Large' },
+		],
+	};
+	const redSmall = {
+		id: 42,
+		variation: [
+			{ attribute: 'Color', value: 'Red' },
+			{ attribute: 'Size', value: 'Small' },
+		],
+	};
+
+	it( 'returns false when the item id differs', () => {
+		expect(
+			matchVariationItem( redLarge, 99, [
+				{ attribute: 'Color', value: 'red' },
+				{ attribute: 'Size', value: 'large' },
+			] )
+		).toBe( false );
+	} );
+
+	it( 'matches an "any" variation by attribute values when ids collide', () => {
+		// Two wishlist rows share id=42 (same variation product, different
+		// "any" slot values). The matcher must pick exactly the one whose
+		// attribute set matches the shopper's current selection.
+		const selectedLarge = [
+			{ attribute: 'Color', value: 'red' },
+			{ attribute: 'Size', value: 'large' },
+		];
+
+		expect( matchVariationItem( redLarge, 42, selectedLarge ) ).toBe(
+			true
+		);
+		expect( matchVariationItem( redSmall, 42, selectedLarge ) ).toBe(
+			false
+		);
+	} );
+
+	it( 'is case-insensitive on values (slug vs term display name)', () => {
+		// Store API returns `value: "Red"`; ATCWO writes `value: "red"`. The
+		// comparison must bridge the case difference.
+		expect(
+			matchVariationItem( redLarge, 42, [
+				{ attribute: 'Color', value: 'red' },
+				{ attribute: 'Size', value: 'large' },
+			] )
+		).toBe( true );
+	} );
+
+	it( 'returns false when the picked attribute set size differs', () => {
+		// Edge: shopper picks just Color; the row has both Color and Size.
+		expect(
+			matchVariationItem( redLarge, 42, [
+				{ attribute: 'Color', value: 'red' },
+			] )
+		).toBe( false );
+	} );
+
+	it( 'treats a missing variation array as empty', () => {
+		// Server omits `variation` for items where the saved product is no
+		// longer purchasable. Should never spuriously match.
+		const noVariation = { id: 42 };
+		expect(
+			matchVariationItem( noVariation, 42, [
+				{ attribute: 'Color', value: 'red' },
+			] )
+		).toBe( false );
+
+		// With no picked attributes and no stored variation, it's a match.
+		expect( matchVariationItem( noVariation, 42, [] ) ).toBe( true );
+	} );
+
+	it( 'returns false when an attribute name differs (label mismatch)', () => {
+		expect(
+			matchVariationItem( redLarge, 42, [
+				{ attribute: 'Colour', value: 'red' },
+				{ attribute: 'Size', value: 'large' },
+			] )
+		).toBe( false );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json
new file mode 100644
index 00000000000..123521deb71
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json
@@ -0,0 +1,44 @@
+{
+	"$schema": "https://schemas.wp.org/trunk/block.json",
+	"apiVersion": 3,
+	"name": "woocommerce/saved-for-later",
+	"version": "1.0.0",
+	"title": "Saved for Later",
+	"description": "Display items the shopper has saved from their cart for later.",
+	"category": "woocommerce",
+	"keywords": [ "WooCommerce", "Saved for Later" ],
+	"textdomain": "woocommerce",
+	"attributes": {
+		"columnCount": {
+			"type": "number",
+			"default": 5
+		}
+	},
+	"allowedBlocks": [ "core/heading" ],
+	"supports": {
+		 "align": [ "wide", "full" ],
+		"interactivity": true,
+		"html": false,
+		"reusable": false,
+		"spacing": {
+			"margin": true,
+			"padding": true,
+			"blockGap": true
+		},
+		"typography": {
+			"fontSize": true,
+			"lineHeight": true
+		},
+		"color": {
+			"text": true,
+			"background": true
+		}
+	},
+	"viewScriptModule": "woocommerce/saved-for-later",
+	"style": [
+		"file:../woocommerce/saved-for-later-style.css",
+		"wc-blocks-style-product-image",
+		"woocommerce-product-price-style",
+		"woocommerce-product-button-style"
+	]
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
new file mode 100644
index 00000000000..2e2d4ea718f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
@@ -0,0 +1,189 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import {
+	useBlockProps,
+	useInnerBlocksProps,
+	InspectorControls,
+} from '@wordpress/block-editor';
+import { PanelBody, RangeControl } from '@wordpress/components';
+import { Icon, trash } from '@wordpress/icons';
+import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
+
+interface SavedForLaterAttributes {
+	columnCount: number;
+}
+
+interface EditProps {
+	attributes: SavedForLaterAttributes;
+	setAttributes: ( attrs: Partial< SavedForLaterAttributes > ) => void;
+}
+
+const MIN_COLUMNS = 2;
+const MAX_COLUMNS = 6;
+
+// Lives in JS because `__()` is needed for the heading copy. `block.json`
+// strings aren't run through translation, so keeping the template here
+// is the only way to ship a localized default.
+const TEMPLATE: [ string, Record< string, unknown > ][] = [
+	[
+		'core/heading',
+		{ content: __( 'Saved for later', 'woocommerce' ), level: 2 },
+	],
+];
+
+const PREVIEW_ITEMS = [
+	{
+		key: 'preview-1',
+		name: __( 'Sample product one', 'woocommerce' ),
+		variation: __( 'Size: M', 'woocommerce' ),
+		price: '$19.99',
+		quantity: __( 'Qty: 2', 'woocommerce' ),
+	},
+	{
+		key: 'preview-2',
+		name: __( 'Sample product two', 'woocommerce' ),
+		variation: __( 'Color: Blue', 'woocommerce' ),
+		price: '$29.99',
+		quantity: __( 'Qty: 1', 'woocommerce' ),
+	},
+	{
+		key: 'preview-3',
+		name: __( 'Sample product three', 'woocommerce' ),
+		variation: '',
+		price: '$9.99',
+		quantity: __( 'Qty: 3', 'woocommerce' ),
+	},
+	{
+		key: 'preview-4',
+		name: __( 'Sample product four', 'woocommerce' ),
+		variation: __( 'Size: L', 'woocommerce' ),
+		price: '$24.99',
+		quantity: __( 'Qty: 1', 'woocommerce' ),
+	},
+	{
+		key: 'preview-5',
+		name: __( 'Sample product five', 'woocommerce' ),
+		variation: '',
+		price: '$14.99',
+		quantity: __( 'Qty: 2', 'woocommerce' ),
+	},
+	{
+		key: 'preview-6',
+		name: __( 'Sample product six', 'woocommerce' ),
+		variation: __( 'Color: Red', 'woocommerce' ),
+		price: '$39.99',
+		quantity: __( 'Qty: 1', 'woocommerce' ),
+	},
+];
+
+const Edit = ( { attributes, setAttributes }: EditProps ): JSX.Element => {
+	const { columnCount } = attributes;
+
+	const blockProps = useBlockProps( {
+		className: 'wc-block-saved-for-later',
+	} );
+
+	// `allowedBlocks` is read from block.json automatically — passing it
+	// here would just duplicate the declaration. `templateLock: false`
+	// is the default so we omit that too. The className matches the
+	// `<div>` PHP wraps `$content` in on the frontend, so any CSS keyed
+	// off `__header` applies in both contexts.
+	const innerBlocksProps = useInnerBlocksProps(
+		{ className: 'wc-block-saved-for-later__header' },
+		{ template: TEMPLATE }
+	);
+
+	return (
+		<>
+			<InspectorControls>
+				<PanelBody title={ __( 'Settings', 'woocommerce' ) }>
+					<RangeControl
+						__next40pxDefaultSize
+						__nextHasNoMarginBottom
+						label={ __( 'Columns', 'woocommerce' ) }
+						value={ columnCount }
+						onChange={ ( value?: number ) => {
+							if ( typeof value !== 'number' ) {
+								return;
+							}
+							setAttributes( { columnCount: value } );
+						} }
+						min={ MIN_COLUMNS }
+						max={ MAX_COLUMNS }
+					/>
+				</PanelBody>
+			</InspectorControls>
+			<section { ...blockProps }>
+				<div { ...innerBlocksProps } />
+				<ul
+					className={ `wc-block-saved-for-later__list columns-${ columnCount }` }
+				>
+					{ PREVIEW_ITEMS.map( ( item ) => (
+						<li
+							key={ item.key }
+							className="wc-block-shopper-list-item"
+						>
+							<div className="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
+								<a
+									href="#preview"
+									onClick={ ( e ) => e.preventDefault() }
+								>
+									<img src={ PLACEHOLDER_IMG_SRC } alt="" />
+								</a>
+								<button
+									type="button"
+									className="wc-block-shopper-list-item__remove"
+									aria-label={ sprintf(
+										/* translators: %s: product name. */
+										__(
+											'Remove %s from Saved for later list',
+											'woocommerce'
+										),
+										item.name
+									) }
+									disabled
+								>
+									<Icon icon={ trash } size={ 24 } />
+								</button>
+								{ item.variation && (
+									<span className="wc-block-shopper-list-item__variation">
+										{ item.variation }
+									</span>
+								) }
+							</div>
+							<h2 className="wp-block-post-title has-text-align-center has-medium-font-size">
+								<a
+									href="#preview"
+									onClick={ ( e ) => e.preventDefault() }
+								>
+									{ item.name }
+								</a>
+							</h2>
+							<div className="price wc-block-components-product-price has-text-align-center has-small-font-size">
+								<span className="wc-block-components-product-price__value">
+									{ item.price }
+								</span>
+							</div>
+							<span className="wc-block-shopper-list-item__quantity">
+								{ item.quantity }
+							</span>
+							<div className="wp-block-button wc-block-components-product-button">
+								<button
+									type="button"
+									className="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+									disabled
+								>
+									{ __( 'Move to cart', 'woocommerce' ) }
+								</button>
+							</div>
+						</li>
+					) ) }
+				</ul>
+			</section>
+		</>
+	);
+};
+
+export default Edit;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
new file mode 100644
index 00000000000..c78f8148c4e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
@@ -0,0 +1,347 @@
+/**
+ * External dependencies
+ */
+import {
+	getConfig,
+	getContext,
+	getElement,
+	store,
+	type AsyncAction,
+} from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/shopper-lists';
+import '@woocommerce/stores/woocommerce/cart';
+import type {
+	RawShopperListItem,
+	Store as ShopperListsStore,
+} from '@woocommerce/stores/woocommerce/shopper-lists';
+import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
+import { sanitizeHTML } from '@woocommerce/sanitize';
+
+const universalLock =
+	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const LIST_SLUG = 'saved-for-later';
+
+type SavedForLaterConfig = {
+	quantityLabelTemplate: string;
+	removeLabelTemplate: string;
+};
+
+type BlockContext = {
+	// Wrapper-scoped flag: starts as `items.length > 0` from SSR and the
+	// `trackShownItems` callback flips it to `true` the first time the
+	// list has any items at runtime. Lives in iAPI context so it resets
+	// on every full page load.
+	hasShownItems: boolean;
+	listItem?: RawShopperListItem;
+	htmlField?: 'price_html' | 'image_html';
+	// Item keys currently mid-mutation, used to disable per-row buttons.
+	pendingKeys: Record< string, true >;
+};
+
+type BlockStore = {
+	state: {
+		currentItems: RawShopperListItem[];
+		isCurrentItemPending: boolean;
+		isEmpty: boolean;
+		isMoveToCartHidden: boolean;
+		isPriceHidden: boolean;
+		currentItemDisplayName: string;
+		currentItemQuantityLabel: string;
+		currentItemRemoveLabel: string;
+		currentItemVariationLabel: string;
+	};
+	actions: {
+		onClickRemove: () => Generator< unknown, void >;
+		onClickMoveToCart: () => Generator< unknown, void >;
+	};
+	callbacks: {
+		updateInnerHtml: () => void;
+		trackShownItems: () => void;
+	};
+};
+
+// Allow-list for sanitizing the schema's preformatted strings on innerHTML
+// swap. Covers what `wc_price` (sale/discount markup, currency symbol) and
+// `wp_get_attachment_image` / `wc_placeholder_img` emit (responsive image
+// + dimensions + lazy loading).
+const ALLOWED_TAGS = [
+	'a',
+	'b',
+	'em',
+	'i',
+	'strong',
+	'p',
+	'br',
+	'span',
+	'bdi',
+	'del',
+	'ins',
+	'img',
+	'picture',
+	'source',
+];
+const ALLOWED_ATTR = [
+	'class',
+	'target',
+	'href',
+	'rel',
+	'name',
+	'download',
+	'aria-hidden',
+	'src',
+	'srcset',
+	'sizes',
+	'alt',
+	'width',
+	'height',
+	'loading',
+	'decoding',
+];
+
+const { state: shopperListsState, actions: shopperListsActions } =
+	store< ShopperListsStore >(
+		'woocommerce/shopper-lists',
+		{},
+		{ lock: universalLock }
+	);
+
+const { state: cartState, actions: cartActions } = store< WooCommerce >(
+	'woocommerce',
+	{},
+	{ lock: universalLock }
+);
+
+const decodeEntities = ( encoded: string ): string => {
+	const txt = document.createElement( 'textarea' );
+	txt.innerHTML = encoded;
+	return txt.value;
+};
+
+const formatVariationLabel = ( item: RawShopperListItem ): string => {
+	if ( ! item.variation || item.variation.length === 0 ) {
+		return '';
+	}
+	return item.variation
+		.map(
+			( v ) =>
+				`${ decodeEntities( v.attribute ) }: ${ decodeEntities(
+					v.value
+				) }`
+		)
+		.join( ', ' );
+};
+
+const getList = ( slug: string ) => shopperListsState.lists[ slug ] ?? null;
+
+store< BlockStore >(
+	'woocommerce/saved-for-later',
+	{
+		state: {
+			get currentItems(): RawShopperListItem[] {
+				return getList( LIST_SLUG )?.items ?? [];
+			},
+
+			get isCurrentItemPending(): boolean {
+				const { listItem, pendingKeys } = getContext< BlockContext >();
+				return !! listItem && !! pendingKeys[ listItem.key ];
+			},
+
+			get isEmpty(): boolean {
+				const list = getList( LIST_SLUG );
+				if ( ! list ) {
+					return false;
+				}
+				const ctx = getContext< BlockContext >();
+				return (
+					ctx.hasShownItems &&
+					! list.isLoading &&
+					list.items.length === 0
+				);
+			},
+
+			get isPriceHidden(): boolean {
+				const { listItem } = getContext< BlockContext >();
+				return ! listItem?.price_html;
+			},
+
+			get isMoveToCartHidden(): boolean {
+				const { listItem } = getContext< BlockContext >();
+				if ( ! listItem ) {
+					return true;
+				}
+				return ! listItem.is_purchasable;
+			},
+
+			// `data-wp-text` writes its argument as text-content without
+			// running entity decoding, so a name returned by the schema as
+			// `Tom &amp; Jerry` would render literally that way. Bind
+			// templates and SSR text spans to this getter instead of the
+			// raw context field so what the browser shows matches what
+			// PHP wrote on first paint.
+			get currentItemDisplayName(): string {
+				const { listItem } = getContext< BlockContext >();
+				return listItem ? decodeEntities( listItem.name ) : '';
+			},
+
+			get currentItemQuantityLabel(): string {
+				const { listItem } = getContext< BlockContext >();
+				if ( ! listItem ) {
+					return '';
+				}
+				const { quantityLabelTemplate } = getConfig(
+					'woocommerce/saved-for-later'
+				) as SavedForLaterConfig;
+				return quantityLabelTemplate.replace(
+					'%d',
+					String( listItem.quantity )
+				);
+			},
+
+			get currentItemRemoveLabel(): string {
+				const { listItem } = getContext< BlockContext >();
+				if ( ! listItem ) {
+					return '';
+				}
+				const { removeLabelTemplate } = getConfig(
+					'woocommerce/saved-for-later'
+				) as SavedForLaterConfig;
+				return removeLabelTemplate.replace(
+					'%s',
+					decodeEntities( listItem.name )
+				);
+			},
+
+			get currentItemVariationLabel(): string {
+				const { listItem } = getContext< BlockContext >();
+				return listItem ? formatVariationLabel( listItem ) : '';
+			},
+		},
+
+		actions: {
+			*onClickRemove(): AsyncAction< void > {
+				const { listItem, pendingKeys } = getContext< BlockContext >();
+				if ( ! listItem || pendingKeys[ listItem.key ] ) {
+					return;
+				}
+				pendingKeys[ listItem.key ] = true;
+				try {
+					yield shopperListsActions.removeItem(
+						LIST_SLUG,
+						listItem.key
+					);
+				} finally {
+					delete pendingKeys[ listItem.key ];
+				}
+			},
+
+			*onClickMoveToCart(): AsyncAction< void > {
+				const { listItem, pendingKeys } = getContext< BlockContext >();
+				if (
+					! listItem ||
+					! listItem.is_purchasable ||
+					pendingKeys[ listItem.key ]
+				) {
+					return;
+				}
+
+				// Map the schema's `variation` shape to the cart's
+				// SelectedAttributes shape. The schema returns the
+				// slug-form attribute under `raw_attribute` (e.g.
+				// `attribute_pa_color`) plus a display label under
+				// `attribute` (e.g. "Color"); the cart matches by the
+				// slug-form, so override `attribute` with `raw_attribute`.
+				// Same swap mini-cart's `changeQuantity` does. Empty for
+				// simple products.
+				const variation = listItem.variation.map(
+					( { raw_attribute: rawAttribute, value, attribute } ) => ( {
+						attribute: rawAttribute || attribute,
+						value,
+					} )
+				);
+				const isVariation = listItem.variation_id > 0;
+
+				// `cartActions.addCartItem` catches its own errors and
+				// surfaces them as store notices, so the yield resolves
+				// the same way on success and failure. Snapshot the
+				// matching line's quantity, run the add, then only remove
+				// from the saved list if it actually grew.
+				const lookup = {
+					id: listItem.id,
+					...( isVariation && { variation } ),
+				};
+				const beforeItem = cartState.findItemInCart( lookup );
+				const beforeQuantity = beforeItem?.quantity ?? 0;
+
+				pendingKeys[ listItem.key ] = true;
+				try {
+					yield cartActions.addCartItem( {
+						id: listItem.id,
+						quantityToAdd: listItem.quantity,
+						type: isVariation ? 'variation' : 'simple',
+						...( isVariation && { variation } ),
+					} );
+
+					const afterItem = cartState.findItemInCart( lookup );
+					const afterQuantity = afterItem?.quantity ?? 0;
+
+					if ( afterQuantity <= beforeQuantity ) {
+						return;
+					}
+
+					yield shopperListsActions.removeItem(
+						LIST_SLUG,
+						listItem.key
+					);
+				} finally {
+					delete pendingKeys[ listItem.key ];
+				}
+			},
+		},
+
+		callbacks: {
+			// Wrapper-level watcher: flips `hasShownItems` to `true` the
+			// first time the list has any items. Pairs with `state.isEmpty`
+			// to gate the empty message — a new shopper landing on a page
+			// with nothing saved keeps the flag at its SSR-seeded `false`
+			// and never sees the message; once they save an item (or
+			// landed with items) the flag is `true`, so emptying the list
+			// from that point surfaces the message. The flag never flips
+			// back to `false`, which is what gives the "had-items → now-empty"
+			// transition we want during the session.
+			trackShownItems: () => {
+				const ctx = getContext< BlockContext >();
+				const list = getList( LIST_SLUG );
+				if ( list && list.items.length > 0 && ! ctx.hasShownItems ) {
+					ctx.hasShownItems = true;
+				}
+			},
+
+			// Single shared innerHTML-swap callback for any slot whose
+			// content is one of the schema's preformatted HTML fields.
+			// Mirrors the atomic product-elements `updateValue` callback:
+			// the watched element carries `data-wp-context='{"htmlField":"price_html"}'`
+			// (or `"image_html"`), and this callback reads that field
+			// off the row's `listItem` and pastes its sanitized HTML into
+			// `element.ref`. PHP renders the same HTML server-side, so
+			// hydration is a no-op when the row's listItem hasn't changed,
+			// and a clean swap when it has (e.g. after Remove shifts the
+			// next item into this slot).
+			updateInnerHtml: () => {
+				const { ref } = getElement();
+				const { listItem, htmlField } = getContext< BlockContext >();
+				if ( ! ref || ! listItem || ! htmlField ) {
+					return;
+				}
+				const html = listItem[ htmlField ];
+				if ( typeof html === 'string' ) {
+					ref.innerHTML = sanitizeHTML( html, {
+						tags: ALLOWED_TAGS,
+						attr: ALLOWED_ATTR,
+					} );
+				}
+			},
+		},
+	},
+	{ lock: universalLock }
+);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/index.tsx
new file mode 100644
index 00000000000..21f24c7656a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/index.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { Icon, starEmpty } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import Save from './save';
+import './style.scss';
+
+registerBlockType( metadata, {
+	icon: {
+		src: (
+			<Icon
+				icon={ starEmpty }
+				className="wc-block-editor-components-block-icon"
+			/>
+		),
+	},
+	edit: Edit,
+	save: Save,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx
new file mode 100644
index 00000000000..6913e34a3d2
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx
@@ -0,0 +1,14 @@
+/**
+ * External dependencies
+ */
+import { InnerBlocks } from '@wordpress/block-editor';
+
+/**
+ * The block is rendered server-side, but the inner blocks (default
+ * `core/heading`) must be serialized to `post_content` so user edits
+ * persist — returning `null` would drop them. `<InnerBlocks.Content />`
+ * emits only the inner blocks' static markup.
+ */
+const Save = (): JSX.Element => <InnerBlocks.Content />;
+
+export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/style.scss
new file mode 100644
index 00000000000..914cdd49153
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/style.scss
@@ -0,0 +1,45 @@
+// Frontend stylesheet for the Saved for Later block.
+// Layout rules for `.wc-block-components-product-image`,
+// `.wc-block-components-product-price` and `.wc-block-components-product-button`
+// come from the atomic product-image / -price / -button blocks' stylesheets,
+// which we declare as style dependencies in `block.json` so WordPress
+// enqueues them whenever this block renders. Don't duplicate those rules here.
+
+@import "../shopper-lists/item";
+
+// The outer `<section>` is a plain wrapper — no layout of its own. The
+// grid lives on the inner `<ul class="wc-block-saved-for-later__list">`
+// so it doesn't try to grid-place the seeded heading inner block.
+// One concrete `.columns-N` class per supported column count so narrow
+// viewports reflow to fewer columns instead of shrinking items uniformly.
+// Mirrors Product Collection's `__responsive` formula.
+$min-item-width: 150px;
+$grid-gap: var(--wp--style--block-gap, 1.5em);
+
+.wc-block-saved-for-later__list {
+	display: grid;
+	gap: $grid-gap;
+	list-style: none;
+	margin: 0;
+	padding: 0;
+
+	@for $i from 2 through 6 {
+		$gap-count: calc(#{$i} - 1);
+		$total-gap-width: calc(#{$gap-count} * #{$grid-gap});
+		$max-item-width: calc((100% - #{$total-gap-width}) / #{$i});
+
+		&.columns-#{ $i } {
+			grid-template-columns: repeat(
+				auto-fill,
+				minmax(max(#{$min-item-width}, #{$max-item-width}), 1fr)
+			);
+		}
+	}
+}
+
+.wc-block-saved-for-later__empty {
+	color: rgba(0, 0, 0, 0.7);
+	grid-column: 1 / -1;
+	padding: 1em 0;
+	text-align: left;
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/shopper-lists/_item.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/shopper-lists/_item.scss
new file mode 100644
index 00000000000..5a61327a83e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/shopper-lists/_item.scss
@@ -0,0 +1,125 @@
+// Row styles shared by every block that renders a shopper-list item card
+// (Saved for Later, Wishlist, …). Consumers `@import` this partial and
+// keep their own layout (grid wrapper, empty-state, header) in their
+// block-local stylesheet.
+
+// Layout rules for `.wc-block-components-product-image`,
+// `.wc-block-components-product-price` and `.wc-block-components-product-button`
+// come from the atomic product-image / -price / -button blocks' stylesheets,
+// which the consuming blocks declare as style dependencies in `block.json`.
+// Don't duplicate those rules here.
+
+.wc-block-shopper-list-item {
+	display: flex;
+	flex-direction: column;
+	gap: 0.25em;
+
+	// Tombstone rows render `<a>` without an href so iAPI can keep
+	// reconciling them against the live-row template (swapping the
+	// element type would force a re-hydration). The browser already
+	// drops link semantics for hrefless anchors, but make the
+	// non-clickability explicit so any inherited link styles don't
+	// mislead pointer or keyboard users.
+	a:not([href]) {
+		cursor: default;
+		pointer-events: none;
+	}
+
+	// The atomic product-button's `display: flex` outspecifies `[hidden]`,
+	// so the action wrapper (Move to cart / Add to cart) wouldn't honor
+	// `hidden` without this.
+	.wp-block-button.wc-block-components-product-button[hidden] {
+		display: none;
+	}
+
+	// Title spacing/line-height matches Product Collection's default
+	// product-title attributes (level: 2, margin: 0 0 0.75rem, line-height: 1.4).
+	// We target wp-block-post-title (core's post-title class) because the
+	// editor preview mirrors PC's editor render, which uses core/post-title.
+	.wp-block-post-title {
+		line-height: 1.4;
+		margin: 0 0 0.75rem;
+	}
+
+	// Quantity has no atomic equivalent in Product Collection, so we own
+	// its styling. Center-aligned and small to mirror the rest of the row
+	// (price + button use the WP `has-small-font-size` preset). Only the
+	// Saved for Later block renders this element — Wishlist omits it — but
+	// the rule lives here so the row's visual language stays consistent
+	// across consumers.
+	&__quantity {
+		color: rgba(0, 0, 0, 0.7);
+		display: block;
+		font-size: var(--wp--preset--font-size--small, 0.875em);
+		text-align: center;
+	}
+
+	// Variation overlay sits at the bottom of the image and shares the
+	// remove badge's border/background/radius so the two corner overlays
+	// read as a pair. Spans the full image width (with the same 4px inset
+	// the remove badge uses on top/right) and is only rendered for
+	// variation products, so when absent the image isn't pushed around.
+	&__variation {
+		background: #fff;
+		border: 1px solid #43454b;
+		border-radius: 4px;
+		bottom: 4px;
+		box-sizing: border-box;
+		color: #43454b;
+		font-size: var(--wp--preset--font-size--small, 0.875em);
+		left: 4px;
+		padding: 0.25em 0.75em;
+		position: absolute;
+		right: 4px;
+		text-align: center;
+		z-index: 10;
+	}
+
+	// Icon-only Remove button overlaying the image. Mirrors the
+	// `woocommerce-product-gallery__trigger` corner-overlay styles
+	// (white fill, circular, black icon, no border) so the affordance
+	// reads consistently with the gallery zoom trigger.
+	&__remove {
+		appearance: none;
+		background: #fff;
+		border: none;
+		border-radius: 100%;
+		box-sizing: border-box;
+		color: #000;
+		cursor: pointer;
+		display: block;
+		line-height: 0;
+		padding: 0.25em;
+		position: absolute;
+		right: 4px;
+		top: 4px;
+		transition: background-color 120ms ease, color 120ms ease;
+		z-index: 10;
+
+		svg {
+			display: block;
+			fill: currentColor;
+		}
+
+		&:hover:not([disabled]) {
+			background: #000;
+			color: #fff;
+		}
+
+		// Keyboard focus indicator. The default :focus-visible ring would
+		// land on a transparent border against the white badge body and
+		// be invisible, so paint our own — outline (so it sits outside the
+		// border without nudging layout) plus a soft offset to keep it
+		// readable over both the badge body and the product image behind
+		// it. Not applied to :hover-only — pointer users keep the invert.
+		&:focus-visible:not([disabled]) {
+			outline: 2px solid #000;
+			outline-offset: 1px;
+		}
+
+		&[disabled] {
+			cursor: not-allowed;
+			opacity: 0.7;
+		}
+	}
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/block.json
new file mode 100644
index 00000000000..837f2daf0d1
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/block.json
@@ -0,0 +1,44 @@
+{
+	"$schema": "https://schemas.wp.org/trunk/block.json",
+	"apiVersion": 3,
+	"name": "woocommerce/wishlist",
+	"version": "1.0.0",
+	"title": "Wishlist",
+	"description": "Display the shopper's wishlist.",
+	"category": "woocommerce",
+	"keywords": [ "WooCommerce", "Wishlist" ],
+	"textdomain": "woocommerce",
+	"attributes": {
+		"columnCount": {
+			"type": "number",
+			"default": 5
+		}
+	},
+	"allowedBlocks": [ "core/heading" ],
+	"supports": {
+		"align": [ "wide", "full" ],
+		"interactivity": true,
+		"html": false,
+		"reusable": false,
+		"spacing": {
+			"margin": true,
+			"padding": true,
+			"blockGap": true
+		},
+		"typography": {
+			"fontSize": true,
+			"lineHeight": true
+		},
+		"color": {
+			"text": true,
+			"background": true
+		}
+	},
+	"viewScriptModule": "woocommerce/wishlist",
+	"style": [
+		"file:../woocommerce/wishlist-style.css",
+		"wc-blocks-style-product-image",
+		"woocommerce-product-price-style",
+		"woocommerce-product-button-style"
+	]
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx
new file mode 100644
index 00000000000..38bc1f98bf6
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx
@@ -0,0 +1,177 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import {
+	useBlockProps,
+	useInnerBlocksProps,
+	InspectorControls,
+} from '@wordpress/block-editor';
+import { PanelBody, RangeControl } from '@wordpress/components';
+import { Icon, trash } from '@wordpress/icons';
+import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
+
+interface WishlistAttributes {
+	columnCount: number;
+}
+
+interface EditProps {
+	attributes: WishlistAttributes;
+	setAttributes: ( attrs: Partial< WishlistAttributes > ) => void;
+}
+
+const MIN_COLUMNS = 2;
+const MAX_COLUMNS = 6;
+
+// Lives in JS because `__()` is needed for the heading copy. `block.json`
+// strings aren't run through translation, so keeping the template here
+// is the only way to ship a localized default.
+const TEMPLATE: [ string, Record< string, unknown > ][] = [
+	[ 'core/heading', { content: __( 'Wishlist', 'woocommerce' ), level: 2 } ],
+];
+
+const PREVIEW_ITEMS = [
+	{
+		key: 'preview-1',
+		name: __( 'Sample product one', 'woocommerce' ),
+		variation: __( 'Size: M', 'woocommerce' ),
+		price: '$19.99',
+	},
+	{
+		key: 'preview-2',
+		name: __( 'Sample product two', 'woocommerce' ),
+		variation: __( 'Color: Blue', 'woocommerce' ),
+		price: '$29.99',
+	},
+	{
+		key: 'preview-3',
+		name: __( 'Sample product three', 'woocommerce' ),
+		variation: '',
+		price: '$9.99',
+	},
+	{
+		key: 'preview-4',
+		name: __( 'Sample product four', 'woocommerce' ),
+		variation: __( 'Size: L', 'woocommerce' ),
+		price: '$24.99',
+	},
+	{
+		key: 'preview-5',
+		name: __( 'Sample product five', 'woocommerce' ),
+		variation: '',
+		price: '$14.99',
+	},
+	{
+		key: 'preview-6',
+		name: __( 'Sample product six', 'woocommerce' ),
+		variation: __( 'Color: Red', 'woocommerce' ),
+		price: '$39.99',
+	},
+];
+
+const Edit = ( { attributes, setAttributes }: EditProps ): JSX.Element => {
+	const { columnCount } = attributes;
+
+	const blockProps = useBlockProps( {
+		className: 'wc-block-wishlist',
+	} );
+
+	// `allowedBlocks` is read from block.json automatically — passing it
+	// here would just duplicate the declaration. `templateLock: false`
+	// is the default so we omit that too. The className matches the
+	// `<div>` PHP wraps `$content` in on the frontend, so any CSS keyed
+	// off `__header` applies in both contexts.
+	const innerBlocksProps = useInnerBlocksProps(
+		{ className: 'wc-block-wishlist__header' },
+		{ template: TEMPLATE }
+	);
+
+	return (
+		<>
+			<InspectorControls>
+				<PanelBody title={ __( 'Settings', 'woocommerce' ) }>
+					<RangeControl
+						__next40pxDefaultSize
+						__nextHasNoMarginBottom
+						label={ __( 'Columns', 'woocommerce' ) }
+						value={ columnCount }
+						onChange={ ( value?: number ) => {
+							if ( typeof value !== 'number' ) {
+								return;
+							}
+							setAttributes( { columnCount: value } );
+						} }
+						min={ MIN_COLUMNS }
+						max={ MAX_COLUMNS }
+					/>
+				</PanelBody>
+			</InspectorControls>
+			<section { ...blockProps }>
+				<div { ...innerBlocksProps } />
+				<ul
+					className={ `wc-block-wishlist__list columns-${ columnCount }` }
+				>
+					{ PREVIEW_ITEMS.map( ( item ) => (
+						<li
+							key={ item.key }
+							className="wc-block-shopper-list-item"
+						>
+							<div className="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
+								<a
+									href="#preview"
+									onClick={ ( e ) => e.preventDefault() }
+								>
+									<img src={ PLACEHOLDER_IMG_SRC } alt="" />
+								</a>
+								<button
+									type="button"
+									className="wc-block-shopper-list-item__remove"
+									aria-label={ sprintf(
+										/* translators: %s: product name. */
+										__(
+											'Remove %s from wishlist',
+											'woocommerce'
+										),
+										item.name
+									) }
+									disabled
+								>
+									<Icon icon={ trash } size={ 24 } />
+								</button>
+								{ item.variation && (
+									<span className="wc-block-shopper-list-item__variation">
+										{ item.variation }
+									</span>
+								) }
+							</div>
+							<h2 className="wp-block-post-title has-text-align-center has-medium-font-size">
+								<a
+									href="#preview"
+									onClick={ ( e ) => e.preventDefault() }
+								>
+									{ item.name }
+								</a>
+							</h2>
+							<div className="price wc-block-components-product-price has-text-align-center has-small-font-size">
+								<span className="wc-block-components-product-price__value">
+									{ item.price }
+								</span>
+							</div>
+							<div className="wp-block-button wc-block-components-product-button">
+								<button
+									type="button"
+									className="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+									disabled
+								>
+									{ __( 'Add to cart', 'woocommerce' ) }
+								</button>
+							</div>
+						</li>
+					) ) }
+				</ul>
+			</section>
+		</>
+	);
+};
+
+export default Edit;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
new file mode 100644
index 00000000000..a9d6451fcc3
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
@@ -0,0 +1,309 @@
+/**
+ * External dependencies
+ */
+import {
+	getConfig,
+	getContext,
+	getElement,
+	store,
+	type AsyncAction,
+} from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/shopper-lists';
+import '@woocommerce/stores/woocommerce/cart';
+import type {
+	RawShopperListItem,
+	Store as ShopperListsStore,
+} from '@woocommerce/stores/woocommerce/shopper-lists';
+import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
+import { sanitizeHTML } from '@woocommerce/sanitize';
+
+const universalLock =
+	'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const LIST_SLUG = 'wishlist';
+
+type WishlistConfig = {
+	removeLabelTemplate: string;
+};
+
+type BlockContext = {
+	listItem?: RawShopperListItem;
+	htmlField?: 'price_html' | 'image_html';
+	// Item keys currently mid-mutation, used to disable per-row buttons.
+	pendingKeys: Record< string, true >;
+};
+
+type BlockStore = {
+	state: {
+		currentItems: RawShopperListItem[];
+		isCurrentItemPending: boolean;
+		isEmpty: boolean;
+		isAddToCartHidden: boolean;
+		isPriceHidden: boolean;
+		currentItemDisplayName: string;
+		currentItemRemoveLabel: string;
+		currentItemVariationLabel: string;
+	};
+	actions: {
+		onClickRemove: () => Generator< unknown, void >;
+		onClickAddToCart: () => Generator< unknown, void >;
+	};
+	callbacks: {
+		updateInnerHtml: () => void;
+	};
+};
+
+// Allow-list for sanitizing the schema's preformatted strings on innerHTML
+// swap. Covers what `wc_price` (sale/discount markup, currency symbol) and
+// `wp_get_attachment_image` / `wc_placeholder_img` emit (responsive image
+// + dimensions + lazy loading).
+const ALLOWED_TAGS = [
+	'a',
+	'b',
+	'em',
+	'i',
+	'strong',
+	'p',
+	'br',
+	'span',
+	'bdi',
+	'del',
+	'ins',
+	'img',
+	'picture',
+	'source',
+];
+const ALLOWED_ATTR = [
+	'class',
+	'target',
+	'href',
+	'rel',
+	'name',
+	'download',
+	'aria-hidden',
+	'src',
+	'srcset',
+	'sizes',
+	'alt',
+	'width',
+	'height',
+	'loading',
+	'decoding',
+];
+
+const { state: shopperListsState, actions: shopperListsActions } =
+	store< ShopperListsStore >(
+		'woocommerce/shopper-lists',
+		{},
+		{ lock: universalLock }
+	);
+
+const { state: cartState, actions: cartActions } = store< WooCommerce >(
+	'woocommerce',
+	{},
+	{ lock: universalLock }
+);
+
+const decodeEntities = ( encoded: string ): string => {
+	const txt = document.createElement( 'textarea' );
+	txt.innerHTML = encoded;
+	return txt.value;
+};
+
+const formatVariationLabel = ( item: RawShopperListItem ): string => {
+	if ( ! item.variation || item.variation.length === 0 ) {
+		return '';
+	}
+	return item.variation
+		.map(
+			( v ) =>
+				`${ decodeEntities( v.attribute ) }: ${ decodeEntities(
+					v.value
+				) }`
+		)
+		.join( ', ' );
+};
+
+const getList = ( slug: string ) => shopperListsState.lists[ slug ] ?? null;
+
+store< BlockStore >(
+	'woocommerce/wishlist',
+	{
+		state: {
+			get currentItems(): RawShopperListItem[] {
+				return getList( LIST_SLUG )?.items ?? [];
+			},
+
+			get isCurrentItemPending(): boolean {
+				const { listItem, pendingKeys } = getContext< BlockContext >();
+				return !! listItem && !! pendingKeys[ listItem.key ];
+			},
+
+			// No `hasShownItems` gate: the visitor reached this block
+			// deliberately (My Account endpoint or merchant-placed), so
+			// showing the empty message immediately when the list is
+			// empty is the right signal.
+			get isEmpty(): boolean {
+				const list = getList( LIST_SLUG );
+				if ( ! list ) {
+					return false;
+				}
+				return ! list.isLoading && list.items.length === 0;
+			},
+
+			get isPriceHidden(): boolean {
+				const { listItem } = getContext< BlockContext >();
+				return ! listItem?.price_html;
+			},
+
+			get isAddToCartHidden(): boolean {
+				const { listItem } = getContext< BlockContext >();
+				if ( ! listItem ) {
+					return true;
+				}
+				return ! listItem.is_purchasable;
+			},
+
+			// `data-wp-text` writes its argument as text-content without
+			// running entity decoding, so a name returned by the schema as
+			// `Tom &amp; Jerry` would render literally that way. Bind
+			// templates and SSR text spans to this getter instead of the
+			// raw context field so what the browser shows matches what
+			// PHP wrote on first paint.
+			get currentItemDisplayName(): string {
+				const { listItem } = getContext< BlockContext >();
+				return listItem ? decodeEntities( listItem.name ) : '';
+			},
+
+			get currentItemRemoveLabel(): string {
+				const { listItem } = getContext< BlockContext >();
+				if ( ! listItem ) {
+					return '';
+				}
+				const { removeLabelTemplate } = getConfig(
+					'woocommerce/wishlist'
+				) as WishlistConfig;
+				return removeLabelTemplate.replace(
+					'%s',
+					decodeEntities( listItem.name )
+				);
+			},
+
+			get currentItemVariationLabel(): string {
+				const { listItem } = getContext< BlockContext >();
+				return listItem ? formatVariationLabel( listItem ) : '';
+			},
+		},
+
+		actions: {
+			*onClickRemove(): AsyncAction< void > {
+				const { listItem, pendingKeys } = getContext< BlockContext >();
+				if ( ! listItem || pendingKeys[ listItem.key ] ) {
+					return;
+				}
+				pendingKeys[ listItem.key ] = true;
+				try {
+					yield shopperListsActions.removeItem(
+						LIST_SLUG,
+						listItem.key
+					);
+				} finally {
+					delete pendingKeys[ listItem.key ];
+				}
+			},
+
+			*onClickAddToCart(): AsyncAction< void > {
+				const { listItem, pendingKeys } = getContext< BlockContext >();
+				if (
+					! listItem ||
+					! listItem.is_purchasable ||
+					pendingKeys[ listItem.key ]
+				) {
+					return;
+				}
+
+				// Map the schema's `variation` shape to the cart's
+				// SelectedAttributes shape. The schema returns the
+				// slug-form attribute under `raw_attribute` (e.g.
+				// `attribute_pa_color`) plus a display label under
+				// `attribute` (e.g. "Color"); the cart matches by the
+				// slug-form, so override `attribute` with `raw_attribute`.
+				// Empty for simple products.
+				const variation = listItem.variation.map(
+					( { raw_attribute: rawAttribute, value, attribute } ) => ( {
+						attribute: rawAttribute || attribute,
+						value,
+					} )
+				);
+				const isVariation = listItem.variation_id > 0;
+
+				// Wishlist always adds quantity 1 (no quantity column).
+				// `cartActions.addCartItem` catches its own errors and
+				// surfaces them as store notices, so the yield resolves
+				// the same way on success and failure. Snapshot the
+				// matching line's quantity, run the add, then only remove
+				// from the wishlist if the cart line actually grew — that
+				// guards against partial-stock and silent-failure paths
+				// where we shouldn't drop the wishlist entry.
+				const lookup = {
+					id: listItem.id,
+					...( isVariation && { variation } ),
+				};
+				const beforeItem = cartState.findItemInCart( lookup );
+				const beforeQuantity = beforeItem?.quantity ?? 0;
+
+				pendingKeys[ listItem.key ] = true;
+				try {
+					yield cartActions.addCartItem( {
+						id: listItem.id,
+						quantityToAdd: 1,
+						type: isVariation ? 'variation' : 'simple',
+						...( isVariation && { variation } ),
+					} );
+
+					const afterItem = cartState.findItemInCart( lookup );
+					const afterQuantity = afterItem?.quantity ?? 0;
+
+					if ( afterQuantity <= beforeQuantity ) {
+						return;
+					}
+
+					yield shopperListsActions.removeItem(
+						LIST_SLUG,
+						listItem.key
+					);
+				} finally {
+					delete pendingKeys[ listItem.key ];
+				}
+			},
+		},
+
+		callbacks: {
+			// Single shared innerHTML-swap callback for any slot whose
+			// content is one of the schema's preformatted HTML fields.
+			// Mirrors the atomic product-elements `updateValue` callback:
+			// the watched element carries `data-wp-context='{"htmlField":"price_html"}'`
+			// (or `"image_html"`), and this callback reads that field
+			// off the row's `listItem` and pastes its sanitized HTML into
+			// `element.ref`. PHP renders the same HTML server-side, so
+			// hydration is a no-op when the row's listItem hasn't changed,
+			// and a clean swap when it has (e.g. after Remove shifts the
+			// next item into this slot).
+			updateInnerHtml: () => {
+				const { ref } = getElement();
+				const { listItem, htmlField } = getContext< BlockContext >();
+				if ( ! ref || ! listItem || ! htmlField ) {
+					return;
+				}
+				const html = listItem[ htmlField ];
+				if ( typeof html === 'string' ) {
+					ref.innerHTML = sanitizeHTML( html, {
+						tags: ALLOWED_TAGS,
+						attr: ALLOWED_ATTR,
+					} );
+				}
+			},
+		},
+	},
+	{ lock: universalLock }
+);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/index.tsx
new file mode 100644
index 00000000000..21f24c7656a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/index.tsx
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { Icon, starEmpty } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import Save from './save';
+import './style.scss';
+
+registerBlockType( metadata, {
+	icon: {
+		src: (
+			<Icon
+				icon={ starEmpty }
+				className="wc-block-editor-components-block-icon"
+			/>
+		),
+	},
+	edit: Edit,
+	save: Save,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx
new file mode 100644
index 00000000000..6913e34a3d2
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx
@@ -0,0 +1,14 @@
+/**
+ * External dependencies
+ */
+import { InnerBlocks } from '@wordpress/block-editor';
+
+/**
+ * The block is rendered server-side, but the inner blocks (default
+ * `core/heading`) must be serialized to `post_content` so user edits
+ * persist — returning `null` would drop them. `<InnerBlocks.Content />`
+ * emits only the inner blocks' static markup.
+ */
+const Save = (): JSX.Element => <InnerBlocks.Content />;
+
+export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/style.scss
new file mode 100644
index 00000000000..9edb9243b73
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/style.scss
@@ -0,0 +1,45 @@
+// Frontend stylesheet for the Wishlist block.
+// Layout rules for `.wc-block-components-product-image`,
+// `.wc-block-components-product-price` and `.wc-block-components-product-button`
+// come from the atomic product-image / -price / -button blocks' stylesheets,
+// which we declare as style dependencies in `block.json` so WordPress
+// enqueues them whenever this block renders. Don't duplicate those rules here.
+
+@import "../shopper-lists/item";
+
+// The outer `<section>` is a plain wrapper — no layout of its own. The
+// grid lives on the inner `<ul class="wc-block-wishlist__list">`
+// so it doesn't try to grid-place the seeded heading inner block.
+// One concrete `.columns-N` class per supported column count so narrow
+// viewports reflow to fewer columns instead of shrinking items uniformly.
+// Mirrors Product Collection's `__responsive` formula.
+$min-item-width: 150px;
+$grid-gap: var(--wp--style--block-gap, 1.5em);
+
+.wc-block-wishlist__list {
+	display: grid;
+	gap: $grid-gap;
+	list-style: none;
+	margin: 0;
+	padding: 0;
+
+	@for $i from 2 through 6 {
+		$gap-count: calc(#{$i} - 1);
+		$total-gap-width: calc(#{$gap-count} * #{$grid-gap});
+		$max-item-width: calc((100% - #{$total-gap-width}) / #{$i});
+
+		&.columns-#{ $i } {
+			grid-template-columns: repeat(
+				auto-fill,
+				minmax(max(#{$min-item-width}, #{$max-item-width}), 1fr)
+			);
+		}
+	}
+}
+
+.wc-block-wishlist__empty {
+	color: rgba(0, 0, 0, 0.7);
+	grid-column: 1 / -1;
+	padding: 1em 0;
+	text-align: left;
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
index 6c899ee5b03..a1277513d2f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
@@ -21,6 +21,7 @@ import {
 	type ConfigOf,
 	type ActionCreatorsOf,
 } from '@wordpress/data/build-types/types';
+import { __ } from '@wordpress/i18n';
 import { cartStore } from '@woocommerce/block-data';

 /**
@@ -160,8 +161,8 @@ export const applyExtensionCartUpdate =

 			if ( ! includeShipping || ! includeBilling ) {
 				const {
-					shipping_address: _,
-					billing_address: __,
+					shipping_address: _shipping,
+					billing_address: _billing,
 					...responseWithoutAddresses
 				} = response;

@@ -484,6 +485,58 @@ export const removeItemFromCart =
 		}
 	};

+/**
+ * Saves a cart line item to the saved-for-later shopper list.
+ *
+ * On success, emits a `wc-blocks_store_sync_required` event with the saved
+ * item in `detail.item` so a `woocommerce/shopper-lists` iAPI store on the
+ * same page (rendered by a Saved for Later block) can splice the row
+ * into its local state — no extra GET, no race window between a slow
+ * refetch and concurrent mutations. Same envelope the cart's iAPI → wp.data
+ * sync uses to ship payloads (`detail.type === 'from_iAPI'` carries
+ * `quantityChanges`); this is the wp.data → iAPI direction of the same
+ * pattern.
+ *
+ * Removing the item from the cart is the caller's responsibility — keep the
+ * two awaits separate so save and remove errors can be reported distinctly.
+ *
+ * @param {string} cartItemKey Cart item to save.
+ */
+export const saveForLater =
+	( cartItemKey: string ) => async (): Promise< { key: string } > => {
+		if (
+			typeof cartItemKey !== 'string' ||
+			cartItemKey.trim().length === 0
+		) {
+			throw new Error(
+				__(
+					'A cart item is required to save it for later.',
+					'woocommerce'
+				)
+			);
+		}
+		const { response } = await apiFetchWithHeaders< {
+			response: { key: string };
+		} >( {
+			path: '/wc/store/v1/shopper-lists/saved-for-later/items',
+			method: 'POST',
+			data: { cart_item_key: cartItemKey },
+			cache: 'no-store',
+		} );
+
+		window.dispatchEvent(
+			new CustomEvent( 'wc-blocks_store_sync_required', {
+				detail: {
+					type: 'shopper-list-item-added',
+					slug: 'saved-for-later',
+					item: response,
+				},
+			} )
+		);
+
+		return response;
+	};
+
 /**
  * Tracks AbortControllers per cart item for cancelling in-flight quantity requests.
  */
@@ -723,6 +776,7 @@ export type Thunks =
 	| typeof removeCoupon
 	| typeof addItemToCart
 	| typeof removeItemFromCart
+	| typeof saveForLater
 	| typeof changeCartItemQuantity
 	| typeof selectShippingRate
 	| typeof updateCustomerData;
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts b/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
index 08c36e0742e..e1cbd76034a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
@@ -130,6 +130,9 @@ const preventBatching = [
 	'/wc/store/v1/checkout',
 	'/wc/store/v1/checkout?__experimental_calc_totals=true',
 	'/wc/store/v1/cart/update-item',
+	// Shopper-lists routes don't declare allow_batch yet. Drop these once
+	// the routes opt into batching server-side.
+	'/wc/store/v1/shopper-lists/saved-for-later/items',
 ];

 /**
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
index 3eb08fdf1c9..5a0eeee4004 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
@@ -46,6 +46,8 @@ const entries = {
 		'./assets/js/base/stores/store-notices.ts',
 	'@woocommerce/stores/woocommerce/products':
 		'./assets/js/base/stores/woocommerce/products.ts',
+	'@woocommerce/stores/woocommerce/shopper-lists':
+		'./assets/js/base/stores/woocommerce/shopper-lists.ts',
 };

 module.exports = {
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-entries.js b/plugins/woocommerce/client/blocks/bin/webpack-entries.js
index e9941ab10a4..35bb14d5260 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-entries.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-entries.js
@@ -115,6 +115,9 @@ const blocks = {
 	'reviews-by-product': {
 		customDir: 'reviews/reviews-by-product',
 	},
+	'saved-for-later': {},
+	wishlist: {},
+	'add-to-wishlist-button': {},
 	'single-product': {},
 	'stock-filter': {},
 	'store-notices': {},
diff --git a/plugins/woocommerce/client/blocks/tsconfig.base.json b/plugins/woocommerce/client/blocks/tsconfig.base.json
index c923c3f976a..d717cc0b033 100644
--- a/plugins/woocommerce/client/blocks/tsconfig.base.json
+++ b/plugins/woocommerce/client/blocks/tsconfig.base.json
@@ -124,6 +124,9 @@
 			"@woocommerce/stores/woocommerce/products": [
 				"assets/js/base/stores/woocommerce/products"
 			],
+			"@woocommerce/stores/woocommerce/shopper-lists": [
+				"assets/js/base/stores/woocommerce/shopper-lists"
+			],
 			"@woocommerce/type-defs/*": [
 				"assets/js/types/type-defs/*"
 			],
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 2bcbad37cff..02c2cfb6258 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -405,6 +405,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\PushNotifications\PushNotifications::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController::class )->register();

 		// Classes inheriting from RestApiControllerBase.
 		$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/phpstan.neon b/plugins/woocommerce/phpstan.neon
index cf81175dfb3..2eec6bbede8 100644
--- a/plugins/woocommerce/phpstan.neon
+++ b/plugins/woocommerce/phpstan.neon
@@ -39,6 +39,17 @@ parameters:
 		WC_TEMPLATE_DEBUG_MODE: bool
 	ignoreErrors:
 		- identifier: missingType.iterableValue
+		# ShopperListItemSchema uses ProductItemTrait only for format_variation_data();
+		# the trait's unused prepare_product_price_response() assumes a ProductSchema parent.
+		-
+			message: '#Call to an undefined method Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\ShopperListItemSchema::get_tax_display_mode\(\)#'
+			path: src/StoreApi/Utilities/ProductItemTrait.php
+		-
+			message: '#Call to an undefined method Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\ShopperListItemSchema::get_price_function_from_tax_display_mode\(\)#'
+			path: src/StoreApi/Utilities/ProductItemTrait.php
+		-
+			message: '#Call to an undefined static method Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\AbstractSchema::prepare_product_price_response\(\)#'
+			path: src/StoreApi/Utilities/ProductItemTrait.php
 	parallel:
 		maximumNumberOfProcesses: 4

diff --git a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
index 8de3d098441..3d2a968c895 100644
--- a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
+++ b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
@@ -3,6 +3,7 @@ namespace Automattic\WooCommerce\Blocks\Assets;

 use Automattic\WooCommerce\Admin\Features\Features;
 use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
 use Automattic\WooCommerce\Blocks\Domain\Services\Hydration;
 use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
 use Exception;
@@ -252,8 +253,9 @@ class AssetDataRegistry {
 			);
 		}

-		$core_data                            = $this->get_core_data();
-		$core_data['experimentalWcRestApiV4'] = Features::is_enabled( 'rest-api-v4' );
+		$core_data                                 = $this->get_core_data();
+		$core_data['experimentalWcRestApiV4']      = Features::is_enabled( 'rest-api-v4' );
+		$core_data['experimentalCartSaveForLater'] = FeaturesUtil::feature_is_enabled( 'cart_save_for_later' );
 		// note this WILL wipe any data already registered to these keys because they are protected.
 		$this->data = array_replace_recursive( $settings, $core_data );
 	}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php
new file mode 100644
index 00000000000..cc088fd2707
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php
@@ -0,0 +1,277 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
+
+/**
+ * Add to Wishlist Button block.
+ *
+ * Single-product trigger UI for the wishlist. Shipped as an inner block of
+ * `woocommerce/add-to-cart-with-options` (ATCWO) via the per-product-type
+ * template parts, so it always renders inside the form's iAPI scope and can
+ * read its `selectedAttributes` context directly. The `ancestor` restriction
+ * in `block.json` prevents merchants from inserting the block outside ATCWO
+ * (where it'd lose iAPI scope and the variation-attribute read would break).
+ *
+ * Hidden for guests and gated by the `product_wishlist` feature flag. On
+ * click, toggles the currently configured product (parent or selected
+ * variation) in the shopper's wishlist via the shared
+ * `woocommerce/shopper-lists` iAPI store. Errors are surfaced through the
+ * page's existing `woocommerce/store-notices` region — no inline notices
+ * area of its own.
+ */
+final class AddToWishlistButton extends AbstractBlock {
+	/**
+	 * The list slug this block writes to.
+	 */
+	private const LIST_SLUG = 'wishlist';
+
+	/**
+	 * Block name.
+	 *
+	 * @var string
+	 */
+	protected $block_name = 'add-to-wishlist-button';
+
+	/**
+	 * Render the block.
+	 *
+	 * @param array     $attributes Block attributes.
+	 * @param string    $content    Block content.
+	 * @param \WP_Block $block      Block instance.
+	 * @return string Rendered block type output.
+	 */
+	protected function render( $attributes, $content, $block ) {
+		// Guests can't have a wishlist — bail before enqueuing assets or
+		// seeding state.
+		if ( ! is_user_logged_in() ) {
+			return '';
+		}
+
+		$post_id = isset( $block->context['postId'] ) ? absint( $block->context['postId'] ) : 0;
+		if ( ! $post_id ) {
+			return '';
+		}
+
+		$product = wc_get_product( $post_id );
+		if ( ! $product instanceof \WC_Product ) {
+			return '';
+		}
+
+		wp_enqueue_script_module( $this->get_full_block_name() );
+
+		$consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+		BlocksSharedState::load_store_config( $consent );
+
+		$items = $this->prefetch_items();
+
+		// Seed the shared shopper-lists store the same way the Wishlist
+		// block does — restUrl + starter nonce + prefetched items. The
+		// two blocks may both render on the same page (e.g. the merchant
+		// drops the Wishlist block into a sidebar of single-product); iAPI's
+		// deep-merge keeps the first writer's payload, so seeding identical
+		// values here is a no-op when Wishlist already ran.
+		wp_interactivity_state(
+			'woocommerce/shopper-lists',
+			array(
+				'restUrl' => get_rest_url(),
+				'nonce'   => wp_create_nonce( 'wc_store_api' ),
+				'lists'   => array(
+					self::LIST_SLUG => array(
+						'items'     => $items,
+						'isLoading' => false,
+					),
+				),
+			)
+		);
+
+		// Visible labels flow through `wp_interactivity_config` so the
+		// JS-side getter can pick the right one based on
+		// `state.isInWishlist`. PHP renders the matching one as the
+		// initial server-side label.
+		wp_interactivity_config(
+			'woocommerce/add-to-wishlist-button',
+			array(
+				'addLabel'           => $this->get_add_label(),
+				'savedLabel'         => $this->get_saved_label(),
+				'selectOptionsLabel' => $this->get_select_options_label(),
+			)
+		);
+
+		$is_variable            = $product->is_type( 'variable' );
+		$initial_is_in_wishlist = $this->is_initial_in_wishlist( $items, $product );
+		$initial_disabled       = $is_variable;
+		$initial_label          = $is_variable
+			? $this->get_select_options_label()
+			: ( $initial_is_in_wishlist ? $this->get_saved_label() : $this->get_add_label() );
+
+		$wrapper_attributes = array(
+			'class'               => 'wc-block-add-to-wishlist-button',
+			'data-wp-interactive' => 'woocommerce/add-to-wishlist-button',
+			'data-wp-context'     => (string) wp_json_encode(
+				array(
+					'productId'      => $product->get_id(),
+					'isVariableType' => $is_variable,
+					'isPending'      => false,
+				)
+			),
+		);
+
+		ob_start();
+		?>
+		<div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes returns escaped attribute markup. ?>>
+			<button
+				type="button"
+				class="wc-block-add-to-wishlist-button__toggle"
+				data-wp-on--click="actions.onClickToggle"
+				data-wp-bind--aria-pressed="state.isInWishlist"
+				data-wp-bind--disabled="state.isDisabled"
+				<?php echo $initial_is_in_wishlist ? 'aria-pressed="true"' : 'aria-pressed="false"'; ?>
+				<?php
+				if ( $initial_disabled ) {
+					echo 'disabled';
+				}
+				?>
+			>
+				<span class="wc-block-add-to-wishlist-button__icon wc-block-add-to-wishlist-button__icon--empty" data-wp-bind--hidden="state.isInWishlist"
+				<?php
+				if ( $initial_is_in_wishlist ) {
+					echo ' hidden';
+				}
+				?>
+				>
+					<?php echo ShopperListRenderer::get_star_empty_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
+				</span>
+				<span class="wc-block-add-to-wishlist-button__icon wc-block-add-to-wishlist-button__icon--filled" data-wp-bind--hidden="!state.isInWishlist"
+				<?php
+				if ( ! $initial_is_in_wishlist ) {
+					echo ' hidden';
+				}
+				?>
+				>
+					<?php echo ShopperListRenderer::get_star_filled_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
+				</span>
+				<span class="wc-block-add-to-wishlist-button__label" data-wp-text="state.currentLabel"><?php echo esc_html( $initial_label ); ?></span>
+			</button>
+		</div>
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Prefetch the wishlist items via `rest_do_request()`. Logged-out
+	 * users short-circuit to an empty list — the route requires
+	 * authentication and we don't want to fire an API call that's only
+	 * going to 401.
+	 *
+	 * @return array<int, array<string, mixed>> Items in the schema response shape.
+	 */
+	private function prefetch_items(): array {
+		if ( ! is_user_logged_in() ) {
+			return array();
+		}
+
+		$request  = new \WP_REST_Request( 'GET', '/wc/store/v1/shopper-lists/' . self::LIST_SLUG . '/items' );
+		$response = rest_do_request( $request );
+
+		if ( $response->is_error() ) {
+			$error   = $response->as_error();
+			$message = $error instanceof \WP_Error ? $error->get_error_message() : 'Unknown error';
+			wc_get_logger()->debug(
+				sprintf( 'Add to Wishlist button prefetch failed: %s', $message ),
+				array(
+					'source' => 'add-to-wishlist-button',
+					'data'   => array( 'slug' => self::LIST_SLUG ),
+				)
+			);
+			return array();
+		}
+
+		$data = $response->get_data();
+		if ( ! is_array( $data ) && ! is_object( $data ) ) {
+			return array();
+		}
+
+		$decoded = json_decode( (string) wp_json_encode( $data ), true );
+		return is_array( $decoded ) ? $decoded : array();
+	}
+
+	/**
+	 * Whether the current product (or its parent, for a variable parent
+	 * with no selection yet) is already in the prefetched wishlist. For
+	 * variable products the SSR star is always empty — we can't know which
+	 * variation the shopper will pick before JS hydrates.
+	 *
+	 * @param array<int, array<string, mixed>> $items   Schema-shape items.
+	 * @param \WC_Product                      $product The product being viewed.
+	 */
+	private function is_initial_in_wishlist( array $items, \WC_Product $product ): bool {
+		if ( $product->is_type( 'variable' ) ) {
+			return false;
+		}
+		$product_id = $product->get_id();
+		foreach ( $items as $item ) {
+			if ( isset( $item['id'] ) && (int) $item['id'] === $product_id ) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Visible label when the product is not in the wishlist.
+	 */
+	private function get_add_label(): string {
+		return __( 'Add to wishlist', 'woocommerce' );
+	}
+
+	/**
+	 * Visible label when the product is already in the wishlist.
+	 */
+	private function get_saved_label(): string {
+		return __( 'Saved to wishlist', 'woocommerce' );
+	}
+
+	/**
+	 * Visible label when the shopper still needs to pick variation
+	 * attributes before the wishlist toggle can resolve to a specific
+	 * variation.
+	 */
+	private function get_select_options_label(): string {
+		return __( 'Select options first', 'woocommerce' );
+	}
+
+	/**
+	 * Get the frontend script handle for this block type. Scripts are
+	 * loaded via `viewScriptModule` in block.json.
+	 *
+	 * @param string|null $key The key of the script to get.
+	 * @return null
+	 */
+	protected function get_block_type_script( $key = null ) {
+		return null;
+	}
+
+	/**
+	 * Get the frontend style handle for this block type. Returning null
+	 * lets WP use the `style` array from block.json.
+	 *
+	 * @return null
+	 */
+	protected function get_block_type_style() {
+		return null;
+	}
+
+	/**
+	 * Disable the editor style handle for this block type.
+	 *
+	 * @return null
+	 */
+	protected function get_block_type_editor_style() {
+		return null;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php b/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
new file mode 100644
index 00000000000..b0d5eb8750b
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
@@ -0,0 +1,529 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+
+/**
+ * Saved for Later block.
+ *
+ * Renders the shopper's "Saved for Later" list, wired to the `shopper-lists`
+ * Store API endpoints via the shared `woocommerce/shopper-lists` iAPI store.
+ * PHP prefetches the list so the first paint is already populated; JS then
+ * takes over for adds, removes, and Move-to-cart.
+ *
+ * The row markup (image, name, price, remove badge, variation overlay) is
+ * shared with other shopper-list blocks via `ShopperListRenderer`. This
+ * class composes those fragments and adds the bits that are unique to
+ * Saved for Later: auto-injection via the Block Hooks API, the
+ * `hasShownItems` empty-state gating, the per-row quantity span, and the
+ * Move-to-cart action button.
+ */
+final class SavedForLater extends AbstractBlock {
+	/**
+	 * The list slug this block renders. Constant — when additional list
+	 * types ship as their own blocks (e.g. Wishlist), each one will
+	 * hardcode its own slug.
+	 */
+	private const LIST_SLUG = 'saved-for-later';
+
+	/**
+	 * Block name.
+	 *
+	 * @var string
+	 */
+	protected $block_name = 'saved-for-later';
+
+	/**
+	 * Initialize this block type.
+	 */
+	protected function initialize(): void {
+		parent::initialize();
+
+		// We do not use `BlockHooksTrait` currently as it has issues with PHPStan.
+		add_filter( 'hooked_block_types', array( $this, 'register_hooked_block' ), 9, 4 );
+		add_filter( 'hooked_block_woocommerce/saved-for-later', array( $this, 'set_hooked_block_attributes' ), 10, 4 );
+	}
+
+	/**
+	 * Auto-inject this block after `woocommerce/cart`, scoped to the cart page.
+	 *
+	 * @param array                                  $hooked_block_types Block names hooked at this position.
+	 * @param string                                 $relative_position  Position of the insertion point.
+	 * @param string                                 $anchor_block_type  Anchor block name.
+	 * @param array|\WP_Post|\WP_Block_Template|null $context            Where the block is being embedded.
+	 * @return array
+	 */
+	public function register_hooked_block( $hooked_block_types, $relative_position, $anchor_block_type, $context ) {
+		if ( 'after' !== $relative_position || 'woocommerce/cart' !== $anchor_block_type ) {
+			return $hooked_block_types;
+		}
+
+		// `wc_get_page_id()` returns -1 when the page option isn't set.
+		$cart_page_id = (int) wc_get_page_id( 'cart' );
+		if ( $cart_page_id <= 0 || ! ( $context instanceof \WP_Post ) || (int) $context->ID !== $cart_page_id ) {
+			return $hooked_block_types;
+		}
+
+		// Don't double-inject if the block is already in the cart page
+		// content.
+		if ( has_block( $this->get_full_block_name(), $context ) ) {
+			return $hooked_block_types;
+		}
+
+		$hooked_block_types[] = $this->get_full_block_name();
+		return $hooked_block_types;
+	}
+
+	/**
+	 * Seed a default heading inner block on the auto-injected block.
+	 *
+	 * @param array|null $parsed_hooked_block The parsed hooked block array, or null to suppress insertion.
+	 * @param string     $hooked_block_type   The hooked block type name.
+	 * @param string     $relative_position   Position of the insertion point.
+	 * @param array      $parsed_anchor_block The anchor block, in parsed block array format.
+	 * @return array|null
+	 */
+	public function set_hooked_block_attributes( $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block ) {
+		if ( null === $parsed_hooked_block || 'after' !== $relative_position ) {
+			return $parsed_hooked_block;
+		}
+		if ( ! isset( $parsed_anchor_block['blockName'] ) || 'woocommerce/cart' !== $parsed_anchor_block['blockName'] ) {
+			return $parsed_hooked_block;
+		}
+
+		// Seed a `core/heading` inner block so freshly-injected instances
+		// ship with the same heading the editor template seeds. We append
+		// unconditionally — extensions are free to hook
+		// `hooked_block_woocommerce/saved-for-later` to add their own
+		// inner blocks, and gating on `empty( innerBlocks )` would silently
+		// suppress our heading whenever any other extension ran first.
+		//
+		// `core/heading` is a static block, so the serialised markup must
+		// match what the editor would have saved (`<h2 class="wp-block-heading">…</h2>`)
+		// or it'll fail block validation when the cart page is opened in the
+		// editor. `attrs.content` mirrors what the editor's template seeds
+		// (`{ content, level }`) so the parsed shape round-trips identically;
+		// the value is the raw string because attrs are JSON-encoded into the
+		// block comment and `esc_html()` would corrupt translations whose text
+		// contains `&`, `<`, etc. The matching `null` push onto `innerContent`
+		// is what makes `WP_Block::render()` walk into the heading when
+		// building `$content`.
+		$list_heading = __( 'Saved for later', 'woocommerce' );
+		$heading_html = '<h2 class="wp-block-heading">' . esc_html( $list_heading ) . '</h2>';
+
+		if ( ! isset( $parsed_hooked_block['innerBlocks'] ) || ! is_array( $parsed_hooked_block['innerBlocks'] ) ) {
+			$parsed_hooked_block['innerBlocks'] = array();
+		}
+		$parsed_hooked_block['innerBlocks'][] = array(
+			'blockName'    => 'core/heading',
+			'attrs'        => array(
+				'level'   => 2,
+				'content' => $list_heading,
+			),
+			'innerBlocks'  => array(),
+			'innerHTML'    => $heading_html,
+			'innerContent' => array( $heading_html ),
+		);
+		if ( ! isset( $parsed_hooked_block['innerContent'] ) || ! is_array( $parsed_hooked_block['innerContent'] ) ) {
+			$parsed_hooked_block['innerContent'] = array();
+		}
+		$parsed_hooked_block['innerContent'][] = null;
+
+		return $parsed_hooked_block;
+	}
+
+	/**
+	 * Render the block.
+	 *
+	 * @param array     $attributes Block attributes.
+	 * @param string    $content    Block content.
+	 * @param \WP_Block $block      Block instance.
+	 * @return string Rendered block type output.
+	 */
+	protected function render( $attributes, $content, $block ) {
+		// Guests have no personal list — bail before enqueuing assets or seeding state.
+		if ( ! is_user_logged_in() ) {
+			return '';
+		}
+
+		// Set from render() (not Cart::enqueue_data via has_block()) so it works when this
+		// block is auto-injected via the Block Hooks API and isn't in stored post_content.
+		if ( wc_get_container()->get( LegacyProxy::class )->call_function( 'is_cart' ) ) {
+			$this->asset_data_registry->add( 'cartPageHasSavedForLater', true );
+		}
+
+		// Clamp to the 2-6 range the SCSS `@for $i from 2 through 6` loop and
+		// the editor `RangeControl` both support. `absint()` first defends
+		// against a code-editor override (the attribute can be set to any
+		// JSON value there); the `min`/`max` then keep the value within the
+		// range where a `&.columns-#{$i}` rule actually exists.
+		$column_count = min( 6, max( 2, absint( $attributes['columnCount'] ?? 5 ) ) );
+
+		wp_enqueue_script_module( $this->get_full_block_name() );
+
+		$consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+		BlocksSharedState::load_store_config( $consent );
+		BlocksSharedState::load_placeholder_image( $consent );
+		// `Move to cart` calls into the shared cart store, which expects
+		// `state.cart.items` and friends. Without this load the cart store
+		// would have no hydrated cart and the action would throw on the
+		// first click.
+		BlocksSharedState::load_cart_state( $consent );
+
+		$items = $this->prefetch_items();
+
+		// Seed the shared shopper-lists store with the rest URL, the
+		// pre-fetched items, and a starter nonce. The starter nonce is
+		// what the cart store also seeds via `state.nonce` — the JS layer
+		// keeps it fresh by reading the `Nonce` response header on every
+		// subsequent request, so this is just the bootstrap value (and
+		// avoids deadlocking mutations that await `isNonceReady` before
+		// any GET has fired).
+		wp_interactivity_state(
+			'woocommerce/shopper-lists',
+			array(
+				'restUrl' => get_rest_url(),
+				'nonce'   => wp_create_nonce( 'wc_store_api' ),
+				'lists'   => array(
+					self::LIST_SLUG => array(
+						'items'     => $items,
+						'isLoading' => false,
+					),
+				),
+			)
+		);
+
+		// Templates flow through `wp_interactivity_config` so the JS-side
+		// getters can interpolate them (`%d`, `%s`). Visible strings (empty
+		// state, error, action label) are rendered server-side and toggled
+		// with directives, so they don't need to ride here too.
+		wp_interactivity_config(
+			'woocommerce/saved-for-later',
+			array(
+				'quantityLabelTemplate' => $this->get_quantity_label_template(),
+				'removeLabelTemplate'   => $this->get_remove_label_template(),
+			)
+		);
+
+		// `hasShownItems` seeds the per-block context so the empty message
+		// stays hidden for new shoppers who land on a page with nothing
+		// saved. The JS-side watcher flips it to `true` the first time the
+		// list has any items (whether that's the SSR seed or a runtime add
+		// via "Save for later"), and `state.isEmpty` only flips on when the
+		// flag is set *and* the list is currently empty. The flag lives in
+		// the per-block context, so it naturally resets on every full page
+		// load — no extra Store API field or persisted flag needed.
+		// `data-wp-context---notices` seeds the store-notices namespace
+		// alongside the block's own context on the same wrapper.
+		$wrapper_attributes = array(
+			'class'                     => 'wc-block-saved-for-later',
+			'data-wp-interactive'       => 'woocommerce/saved-for-later',
+			'data-wp-context'           => (string) wp_json_encode(
+				array(
+					'hasShownItems' => ! empty( $items ),
+					// `stdClass` so it serialises as `{}`, not `[]` —
+					// iAPI's reactive proxy only fires updates on object
+					// writes, not array expandos.
+					'pendingKeys'   => new \stdClass(),
+				)
+			),
+			'data-wp-context---notices' => 'woocommerce/store-notices::' . (string) wp_json_encode( array( 'notices' => array() ) ),
+			'data-wp-watch'             => 'callbacks.trackShownItems',
+		);
+
+		$list_class = sprintf( 'wc-block-saved-for-later__list columns-%d', $column_count );
+
+		$ul_inner    = $this->render_template_markup() . $this->render_items_markup( $items ) . $this->render_empty_markup();
+		$before_list = $this->render_header_markup( $content, empty( $items ) ) . ShopperListRenderer::render_interactivity_notices_region( 'wc-block-saved-for-later__notices' );
+
+		return ShopperListRenderer::render_grid_wrapper( $wrapper_attributes, $list_class, $ul_inner, $before_list );
+	}
+
+	/**
+	 * Prefetch the saved-for-later items via `rest_do_request()`. Logged-out
+	 * users short-circuit to an empty list — the route requires authentication
+	 * and we don't want to fire an API call that's only going to 401.
+	 *
+	 * @return array<int, array<string, mixed>> Items in the schema response shape.
+	 */
+	private function prefetch_items(): array {
+		if ( ! is_user_logged_in() ) {
+			return array();
+		}
+
+		$request  = new \WP_REST_Request( 'GET', '/wc/store/v1/shopper-lists/' . self::LIST_SLUG . '/items' );
+		$response = rest_do_request( $request );
+
+		if ( $response->is_error() ) {
+			$error   = $response->as_error();
+			$message = $error instanceof \WP_Error ? $error->get_error_message() : 'Unknown error';
+			// Logged at debug level on purpose: prefetch failures are
+			// often transient (network blips, auth refresh races) and
+			// the user-visible behaviour is the empty state — nothing
+			// for ops to act on. Anyone investigating a regression can
+			// flip the WC logger to debug to surface them.
+			wc_get_logger()->debug(
+				sprintf( 'Saved for Later prefetch failed: %s', $message ),
+				array(
+					'source' => 'saved-for-later',
+					'data'   => array( 'slug' => self::LIST_SLUG ),
+				)
+			);
+			return array();
+		}
+
+		$data = $response->get_data();
+		if ( ! is_array( $data ) && ! is_object( $data ) ) {
+			return array();
+		}
+
+		// The schema casts `prices` and image entries to stdClass so the
+		// JSON response renders objects, not arrays. Round-trip through
+		// JSON encode/decode to normalise everything to nested arrays so
+		// the SSR markup helpers below can treat fields uniformly.
+		$decoded = json_decode( (string) wp_json_encode( $data ), true );
+		return is_array( $decoded ) ? $decoded : array();
+	}
+
+	/**
+	 * The `<template data-wp-each>` describing how each item is rendered on
+	 * the client. Pre-rendered children sit alongside as `data-wp-each-child`
+	 * elements so first paint is populated. Composes the shared row markup
+	 * with Saved for Later's quantity span and Move-to-cart action button.
+	 *
+	 * @return string
+	 */
+	private function render_template_markup(): string {
+		$row_inner = ShopperListRenderer::render_template_common_row()
+			. $this->render_template_quantity()
+			. $this->render_template_move_to_cart();
+		return ShopperListRenderer::render_each_template( $row_inner );
+	}
+
+	/**
+	 * Render the SSR markup for each item. JS will reconcile these via
+	 * `data-wp-each-child` after hydration.
+	 *
+	 * @param array<int, array<string, mixed>> $items Schema-shape items.
+	 * @return string
+	 */
+	private function render_items_markup( array $items ): string {
+		$markup = '';
+		foreach ( $items as $item ) {
+			$markup .= $this->render_item_markup( $item );
+		}
+		return $markup;
+	}
+
+	/**
+	 * Render a single SSR item. Composes the shared image / name / price
+	 * markup with the SFL-specific quantity span and Move-to-cart button.
+	 *
+	 * @param array<string, mixed> $item Schema-shape item.
+	 * @return string
+	 */
+	private function render_item_markup( array $item ): string {
+		$row_inner = ShopperListRenderer::render_ssr_common_row( $item, $this->get_remove_label_template() )
+			. $this->render_ssr_quantity( $item )
+			. $this->render_ssr_move_to_cart( $item );
+		return ShopperListRenderer::render_each_child( $item, $row_inner );
+	}
+
+	/**
+	 * Template-mode markup for the quantity span. SFL-specific — Wishlist
+	 * has no quantity column.
+	 *
+	 * @return string
+	 */
+	private function render_template_quantity(): string {
+		return sprintf(
+			'<span class="%s__quantity" data-wp-text="state.currentItemQuantityLabel"></span>',
+			esc_attr( ShopperListRenderer::ROW_CLASS )
+		);
+	}
+
+	/**
+	 * Template-mode markup for the Move-to-cart action button. SFL-specific.
+	 *
+	 * @return string
+	 */
+	private function render_template_move_to_cart(): string {
+		ob_start();
+		?>
+		<div class="wp-block-button wc-block-components-product-button" data-wp-bind--hidden="state.isMoveToCartHidden">
+			<button
+				type="button"
+				class="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+				data-wp-on--click="actions.onClickMoveToCart"
+				data-wp-bind--disabled="state.isCurrentItemPending"
+			>
+				<?php echo esc_html( $this->get_move_to_cart_label() ); ?>
+			</button>
+		</div>
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * SSR-mode markup for the quantity span. SFL-specific.
+	 *
+	 * @param array<string, mixed> $item Schema-shape item.
+	 * @return string
+	 */
+	private function render_ssr_quantity( array $item ): string {
+		$quantity       = (int) ( $item['quantity'] ?? 1 );
+		$quantity_label = sprintf( $this->get_quantity_label_template(), $quantity );
+		return sprintf(
+			'<span class="%s__quantity">%s</span>',
+			esc_attr( ShopperListRenderer::ROW_CLASS ),
+			esc_html( $quantity_label )
+		);
+	}
+
+	/**
+	 * SSR-mode markup for the Move-to-cart action button. SFL-specific.
+	 * Always emits the wrapper so iAPI can toggle `hidden` after hydration
+	 * without swapping the row out. Starts hidden when the row isn't
+	 * purchasable.
+	 *
+	 * @param array<string, mixed> $item Schema-shape item.
+	 * @return string
+	 */
+	private function render_ssr_move_to_cart( array $item ): string {
+		$is_move_to_cart_hidden = empty( $item['is_purchasable'] );
+		ob_start();
+		?>
+		<div
+			class="wp-block-button wc-block-components-product-button"
+			data-wp-bind--hidden="state.isMoveToCartHidden"
+			<?php
+			if ( $is_move_to_cart_hidden ) {
+				echo 'hidden';
+			}
+			?>
+		>
+			<button
+				type="button"
+				class="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+				data-wp-on--click="actions.onClickMoveToCart"
+				data-wp-bind--disabled="state.isCurrentItemPending"
+			>
+				<?php echo esc_html( $this->get_move_to_cart_label() ); ?>
+			</button>
+		</div>
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Wrap the inner-block content (heading + any future siblings) in an
+	 * element whose visibility mirrors the empty-state gating: hidden when
+	 * the shopper has never seen items in this session, revealed once
+	 * `context.hasShownItems` flips to `true`. Returns an empty string when
+	 * there's no content to wrap (e.g. merchant deleted the heading and
+	 * saved), so we don't emit an empty `<div>`.
+	 *
+	 * @param string $content  Rendered inner-block content (typically the heading HTML).
+	 * @param bool   $is_empty Whether the saved-for-later list is empty on initial paint.
+	 * @return string
+	 */
+	private function render_header_markup( string $content, bool $is_empty ): string {
+		if ( '' === $content ) {
+			return '';
+		}
+		$hidden_attr = $is_empty ? ' hidden' : '';
+		return sprintf(
+			'<div class="wc-block-saved-for-later__header" data-wp-bind--hidden="!context.hasShownItems"%s>%s</div>',
+			$hidden_attr,
+			$content
+		);
+	}
+
+	/**
+	 * Render the empty-state markup. Always present in the DOM so JS can
+	 * toggle it on once the last item is removed. Initially hidden: SSR
+	 * never shows the message, since `state.isEmpty` requires the JS-side
+	 * `hasShownItems` context flag to flip first.
+	 *
+	 * @return string
+	 */
+	private function render_empty_markup(): string {
+		return ShopperListRenderer::render_empty_state(
+			__( 'Nothing saved yet — items you save from the cart will appear here.', 'woocommerce' ),
+			'wc-block-saved-for-later__empty',
+			true
+		);
+	}
+
+	/**
+	 * Sprintf template for the per-row quantity label. Used both by PHP SSR
+	 * (`render_ssr_quantity()`) and by the JS-side getter (via
+	 * `wp_interactivity_config`) so both paths produce the same string after
+	 * `%d` interpolation.
+	 */
+	private function get_quantity_label_template(): string {
+		/* translators: %d: quantity of saved items. */
+		return __( 'Quantity: %d', 'woocommerce' );
+	}
+
+	/**
+	 * Sprintf template for the per-row remove button's aria-label. Same dual
+	 * use as the quantity template.
+	 */
+	private function get_remove_label_template(): string {
+		/* translators: %s: product name. */
+		return __( 'Remove %s from Saved for later list', 'woocommerce' );
+	}
+
+	/**
+	 * Visible label for the move-to-cart action button, used by both the
+	 * iAPI `<template>` and the SSR per-row markup.
+	 */
+	private function get_move_to_cart_label(): string {
+		return __( 'Move to cart', 'woocommerce' );
+	}
+
+	/**
+	 * Get the frontend script handle for this block type.
+	 *
+	 * Scripts are loaded via `viewScriptModule` in block.json.
+	 *
+	 * @param string|null $key The key of the script to get.
+	 * @return null
+	 */
+	protected function get_block_type_script( $key = null ) {
+		return null;
+	}
+
+	/**
+	 * Get the frontend style handle for this block type.
+	 *
+	 * Returning null lets WP use the `style` array from block.json, which
+	 * lists this block's own stylesheet plus the atomic
+	 * product-image / product-price / product-button stylesheets we
+	 * borrow class names from. We can't render those atomic blocks as
+	 * inner blocks (they rely on WP_Query / $post loop context, which
+	 * this block doesn't have — it hydrates from a Store API call), so
+	 * declaring them as style dependencies is the only way to get WP
+	 * to enqueue their CSS whenever Saved for Later renders.
+	 *
+	 * @return null
+	 */
+	protected function get_block_type_style() {
+		return null;
+	}
+
+	/**
+	 * Disable the editor style handle for this block type.
+	 *
+	 * @return null
+	 */
+	protected function get_block_type_editor_style() {
+		return null;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php b/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php
new file mode 100644
index 00000000000..1a26fb39d39
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php
@@ -0,0 +1,370 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
+
+/**
+ * Wishlist block.
+ *
+ * Renders the shopper's wishlist, wired to the `shopper-lists` Store API
+ * endpoints via the shared `woocommerce/shopper-lists` iAPI store. PHP
+ * prefetches the list so the first paint is already populated; JS then
+ * takes over for adds, removes, and the per-row "Add to cart" action.
+ *
+ * Unlike Saved for Later, this block is merchant-placed — no Block Hooks
+ * API integration. It's rendered by the `/my-account/wishlist/` endpoint
+ * (gated by the `product_wishlist` feature flag) and can also be placed
+ * on any other page or template. "Add to cart" mirrors Saved for Later's
+ * Move-to-cart flow: add the product to the cart, then remove it from the
+ * wishlist on confirmed success.
+ */
+final class Wishlist extends AbstractBlock {
+	/**
+	 * The list slug this block renders. Constant — when additional list
+	 * types ship as their own blocks, each one hardcodes its own slug.
+	 */
+	private const LIST_SLUG = 'wishlist';
+
+	/**
+	 * Block name.
+	 *
+	 * @var string
+	 */
+	protected $block_name = 'wishlist';
+
+	/**
+	 * Render the block.
+	 *
+	 * @param array     $attributes Block attributes.
+	 * @param string    $content    Block content.
+	 * @param \WP_Block $block      Block instance.
+	 * @return string Rendered block type output.
+	 */
+	protected function render( $attributes, $content, $block ) {
+		// Guests have no personal list — bail before enqueuing assets or
+		// seeding state. The My Account endpoint isn't reachable for
+		// guests, but the block can also be placed by a merchant on any
+		// page, where this guard is what stops it from rendering an
+		// empty shell for logged-out visitors.
+		if ( ! is_user_logged_in() ) {
+			return '';
+		}
+
+		// Clamp to the 2-6 range the SCSS `@for $i from 2 through 6` loop
+		// and the editor `RangeControl` both support. `absint()` first
+		// defends against a code-editor override (the attribute can be set
+		// to any JSON value there); the `min`/`max` then keep the value
+		// within the range where a `&.columns-#{$i}` rule actually exists.
+		$column_count = min( 6, max( 2, absint( $attributes['columnCount'] ?? 5 ) ) );
+
+		wp_enqueue_script_module( $this->get_full_block_name() );
+
+		$consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
+		BlocksSharedState::load_store_config( $consent );
+		BlocksSharedState::load_placeholder_image( $consent );
+		// `Add to cart` calls into the shared cart store, which expects
+		// `state.cart.items` and friends. Without this load the cart store
+		// would have no hydrated cart and the action would throw on the
+		// first click.
+		BlocksSharedState::load_cart_state( $consent );
+
+		$items = $this->prefetch_items();
+
+		// Seed the shared shopper-lists store with the rest URL, the
+		// pre-fetched items, and a starter nonce. The starter nonce is
+		// what the cart store also seeds via `state.nonce` — the JS layer
+		// keeps it fresh by reading the `Nonce` response header on every
+		// subsequent request, so this is just the bootstrap value (and
+		// avoids deadlocking mutations that await `isNonceReady` before
+		// any GET has fired).
+		wp_interactivity_state(
+			'woocommerce/shopper-lists',
+			array(
+				'restUrl' => get_rest_url(),
+				'nonce'   => wp_create_nonce( 'wc_store_api' ),
+				'lists'   => array(
+					self::LIST_SLUG => array(
+						'items'     => $items,
+						'isLoading' => false,
+					),
+				),
+			)
+		);
+
+		// Only the remove-button aria-label template needs JS-side
+		// interpolation; visible strings (empty state, action label) are
+		// rendered server-side and toggled with directives.
+		wp_interactivity_config(
+			'woocommerce/wishlist',
+			array(
+				'removeLabelTemplate' => $this->get_remove_label_template(),
+			)
+		);
+
+		// No `hasShownItems` flag: unlike Saved for Later (which auto-
+		// renders on every cart visit and must avoid flashing an empty
+		// message before a runtime save lands), Wishlist is reached
+		// deliberately — by the My Account endpoint or because a merchant
+		// placed it. Showing the empty message immediately is the right
+		// signal: the visitor came to look at their wishlist, and it's
+		// empty. `data-wp-context---notices` seeds the store-notices
+		// namespace alongside the block's own context on the same wrapper.
+		$wrapper_attributes = array(
+			'class'                     => 'wc-block-wishlist',
+			'data-wp-interactive'       => 'woocommerce/wishlist',
+			'data-wp-context'           => (string) wp_json_encode(
+				array(
+					// `stdClass` so it serialises as `{}`, not `[]` —
+					// iAPI's reactive proxy only fires updates on object
+					// writes, not array expandos.
+					'pendingKeys' => new \stdClass(),
+				)
+			),
+			'data-wp-context---notices' => 'woocommerce/store-notices::' . (string) wp_json_encode( array( 'notices' => array() ) ),
+		);
+
+		$list_class  = sprintf( 'wc-block-wishlist__list columns-%d', $column_count );
+		$ul_inner    = $this->render_template_markup() . $this->render_items_markup( $items ) . $this->render_empty_markup( $items );
+		$before_list = $this->render_header_markup( $content ) . ShopperListRenderer::render_interactivity_notices_region( 'wc-block-wishlist__notices' );
+
+		return ShopperListRenderer::render_grid_wrapper( $wrapper_attributes, $list_class, $ul_inner, $before_list );
+	}
+
+	/**
+	 * Prefetch the wishlist items via `rest_do_request()`. Logged-out
+	 * users short-circuit to an empty list — the route requires
+	 * authentication and we don't want to fire an API call that's only
+	 * going to 401.
+	 *
+	 * @return array<int, array<string, mixed>> Items in the schema response shape.
+	 */
+	private function prefetch_items(): array {
+		if ( ! is_user_logged_in() ) {
+			return array();
+		}
+
+		$request  = new \WP_REST_Request( 'GET', '/wc/store/v1/shopper-lists/' . self::LIST_SLUG . '/items' );
+		$response = rest_do_request( $request );
+
+		if ( $response->is_error() ) {
+			$error   = $response->as_error();
+			$message = $error instanceof \WP_Error ? $error->get_error_message() : 'Unknown error';
+			// Logged at debug level on purpose: prefetch failures are
+			// often transient (network blips, auth refresh races) and
+			// the user-visible behaviour is the empty state — nothing
+			// for ops to act on.
+			wc_get_logger()->debug(
+				sprintf( 'Wishlist prefetch failed: %s', $message ),
+				array(
+					'source' => 'wishlist',
+					'data'   => array( 'slug' => self::LIST_SLUG ),
+				)
+			);
+			return array();
+		}
+
+		$data = $response->get_data();
+		if ( ! is_array( $data ) && ! is_object( $data ) ) {
+			return array();
+		}
+
+		// The schema casts `prices` and image entries to stdClass so the
+		// JSON response renders objects, not arrays. Round-trip through
+		// JSON encode/decode to normalise everything to nested arrays so
+		// the SSR markup helpers can treat fields uniformly.
+		$decoded = json_decode( (string) wp_json_encode( $data ), true );
+		return is_array( $decoded ) ? $decoded : array();
+	}
+
+	/**
+	 * The `<template data-wp-each>` describing how each item is rendered
+	 * on the client. Pre-rendered children sit alongside as
+	 * `data-wp-each-child` elements so first paint is populated. Composes
+	 * the shared row markup with the Wishlist-specific "Add to cart"
+	 * action button.
+	 *
+	 * @return string
+	 */
+	private function render_template_markup(): string {
+		$row_inner = ShopperListRenderer::render_template_common_row()
+			. $this->render_template_add_to_cart();
+		return ShopperListRenderer::render_each_template( $row_inner );
+	}
+
+	/**
+	 * Render the SSR markup for each item. JS will reconcile these via
+	 * `data-wp-each-child` after hydration.
+	 *
+	 * @param array<int, array<string, mixed>> $items Schema-shape items.
+	 * @return string
+	 */
+	private function render_items_markup( array $items ): string {
+		$markup = '';
+		foreach ( $items as $item ) {
+			$markup .= $this->render_item_markup( $item );
+		}
+		return $markup;
+	}
+
+	/**
+	 * Render a single SSR item. Composes the shared image / name / price
+	 * markup with the Wishlist-specific "Add to cart" button.
+	 *
+	 * @param array<string, mixed> $item Schema-shape item.
+	 * @return string
+	 */
+	private function render_item_markup( array $item ): string {
+		$row_inner = ShopperListRenderer::render_ssr_common_row( $item, $this->get_remove_label_template() )
+			. $this->render_ssr_add_to_cart( $item );
+		return ShopperListRenderer::render_each_child( $item, $row_inner );
+	}
+
+	/**
+	 * Template-mode markup for the "Add to cart" action button. iAPI
+	 * substitutes the per-row state through `data-wp-bind--hidden` and
+	 * `data-wp-bind--disabled`.
+	 *
+	 * @return string
+	 */
+	private function render_template_add_to_cart(): string {
+		ob_start();
+		?>
+		<div class="wp-block-button wc-block-components-product-button" data-wp-bind--hidden="state.isAddToCartHidden">
+			<button
+				type="button"
+				class="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+				data-wp-on--click="actions.onClickAddToCart"
+				data-wp-bind--disabled="state.isCurrentItemPending"
+			>
+				<?php echo esc_html( $this->get_add_to_cart_label() ); ?>
+			</button>
+		</div>
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * SSR-mode markup for the "Add to cart" action button. Always emits
+	 * the wrapper so iAPI can toggle `hidden` after hydration without
+	 * swapping the row out. Starts hidden when the row isn't purchasable.
+	 *
+	 * @param array<string, mixed> $item Schema-shape item.
+	 * @return string
+	 */
+	private function render_ssr_add_to_cart( array $item ): string {
+		$is_hidden = empty( $item['is_purchasable'] );
+		ob_start();
+		?>
+		<div
+			class="wp-block-button wc-block-components-product-button"
+			data-wp-bind--hidden="state.isAddToCartHidden"
+			<?php
+			if ( $is_hidden ) {
+				echo 'hidden';
+			}
+			?>
+		>
+			<button
+				type="button"
+				class="wp-block-button__link wp-element-button add_to_cart_button wc-block-components-product-button__button"
+				data-wp-on--click="actions.onClickAddToCart"
+				data-wp-bind--disabled="state.isCurrentItemPending"
+			>
+				<?php echo esc_html( $this->get_add_to_cart_label() ); ?>
+			</button>
+		</div>
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Wrap the inner-block content (heading + any future siblings) in a
+	 * div. Unlike Saved for Later, no `hasShownItems` gating — the header
+	 * is always shown when there's content for it. Returns an empty
+	 * string when there's no content to wrap, so we don't emit an empty
+	 * `<div>`.
+	 *
+	 * @param string $content Rendered inner-block content (typically the heading HTML).
+	 * @return string
+	 */
+	private function render_header_markup( string $content ): string {
+		if ( '' === $content ) {
+			return '';
+		}
+		return '<div class="wc-block-wishlist__header">' . $content . '</div>';
+	}
+
+	/**
+	 * Render the empty-state markup. Visible on first paint when the
+	 * list is empty (no `hasShownItems` gate), then iAPI takes over via
+	 * `state.isEmpty` for runtime transitions.
+	 *
+	 * @param array<int, array<string, mixed>> $items Schema-shape items.
+	 * @return string
+	 */
+	private function render_empty_markup( array $items ): string {
+		return ShopperListRenderer::render_empty_state(
+			__( 'Your wishlist is empty. Items you add to your wishlist will appear here.', 'woocommerce' ),
+			'wc-block-wishlist__empty',
+			! empty( $items )
+		);
+	}
+
+	/**
+	 * Sprintf template for the per-row remove button's aria-label. Used
+	 * both by PHP SSR and by the JS-side getter (via
+	 * `wp_interactivity_config`) so both paths produce the same string
+	 * after `%s` interpolation.
+	 */
+	private function get_remove_label_template(): string {
+		/* translators: %s: product name. */
+		return __( 'Remove %s from wishlist', 'woocommerce' );
+	}
+
+	/**
+	 * Visible label for the add-to-cart action button, used by both the
+	 * iAPI `<template>` and the SSR per-row markup.
+	 */
+	private function get_add_to_cart_label(): string {
+		return __( 'Add to cart', 'woocommerce' );
+	}
+
+	/**
+	 * Get the frontend script handle for this block type.
+	 *
+	 * Scripts are loaded via `viewScriptModule` in block.json.
+	 *
+	 * @param string|null $key The key of the script to get.
+	 * @return null
+	 */
+	protected function get_block_type_script( $key = null ) {
+		return null;
+	}
+
+	/**
+	 * Get the frontend style handle for this block type.
+	 *
+	 * Returning null lets WP use the `style` array from block.json, which
+	 * lists this block's own stylesheet plus the atomic
+	 * product-image / product-price / product-button stylesheets we
+	 * borrow class names from.
+	 *
+	 * @return null
+	 */
+	protected function get_block_type_style() {
+		return null;
+	}
+
+	/**
+	 * Disable the editor style handle for this block type.
+	 *
+	 * @return null
+	 */
+	protected function get_block_type_editor_style() {
+		return null;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index 046a9111098..ba0c6f381de 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
 use Automattic\WooCommerce\Blocks\BlockTypes\Cart;
 use Automattic\WooCommerce\Blocks\BlockTypes\Checkout;
 use Automattic\WooCommerce\Blocks\BlockTypes\MiniCartContents;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;

 /**
  * BlockTypesController class.
@@ -555,6 +556,15 @@ final class BlockTypesController {
 			MiniCartContents::get_mini_cart_block_types()
 		);

+		if ( wc_get_container()->get( ShopperListsController::class )->is_enabled( 'saved-for-later' ) ) {
+			$block_types[] = 'SavedForLater';
+		}
+
+		if ( wc_get_container()->get( ShopperListsController::class )->is_enabled( 'wishlist' ) ) {
+			$block_types[] = 'Wishlist';
+			$block_types[] = 'AddToWishlistButton';
+		}
+
 		if ( wp_is_block_theme() ) {
 			$block_types[] = 'AddToCartWithOptions\AddToCartWithOptions';
 			$block_types[] = 'AddToCartWithOptions\QuantitySelector';
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 1cc46589994..501c86d507b 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -639,6 +639,30 @@ class FeaturesController {
 				'skip_compatibility_checks'    => true,
 				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
 			),
+			'cart_save_for_later'                => array(
+				'name'                         => __( 'Save for Later in Cart', 'woocommerce' ),
+				'description'                  => __(
+					'Let shoppers save cart items to a list to purchase later.',
+					'woocommerce'
+				),
+				'is_experimental'              => true,
+				'enabled_by_default'           => false,
+				// Custom option_key as we expect this setting to move out of features to
+				// a cart/checkout settings section.
+				'option_key'                   => 'woocommerce_cart_save_for_later_enabled',
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+			),
+			'product_wishlist'                   => array(
+				'name'                         => __( 'Wishlists', 'woocommerce' ),
+				'description'                  => __(
+					'Let shoppers save products to a wishlist from product pages. Requires the Add to Cart + Options block on the single-product template.',
+					'woocommerce'
+				),
+				'is_experimental'              => true,
+				'enabled_by_default'           => false,
+				'option_key'                   => 'woocommerce_product_wishlist_enabled',
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+			),
 			ProductCacheController::FEATURE_NAME => array(
 				'name'                         => __( 'Cache Product Objects', 'woocommerce' ),
 				'description'                  => __(
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
new file mode 100644
index 00000000000..48fa653dfff
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
@@ -0,0 +1,238 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\Utilities\Users;
+
+/**
+ * A user's saved list of products.
+ */
+class ShopperList {
+	/**
+	 * Prefix for per-list usermeta key for list details.
+	 */
+	const META_KEY_PREFIX = '_wc_shopper_list_';
+
+	/**
+	 * User ID.
+	 *
+	 * @var int
+	 */
+	private $user_id;
+
+	/**
+	 * List slug.
+	 *
+	 * @var string
+	 */
+	private $slug;
+
+	/**
+	 * Datetime the list was created.
+	 *
+	 * @var string
+	 */
+	private $date_created_gmt;
+
+	/**
+	 * Items in the list.
+	 *
+	 * @var array<string, ShopperListItem>
+	 */
+	private $items;
+
+	/**
+	 * Private constructor. Use the static factories to obtain concrete instances.
+	 *
+	 * @param int                            $user_id          Owning user ID.
+	 * @param string                         $slug             List slug.
+	 * @param string                         $date_created_gmt MySQL DATETIME, GMT.
+	 * @param array<string, ShopperListItem> $items            Items keyed by storage key.
+	 */
+	private function __construct(
+		int $user_id,
+		string $slug,
+		string $date_created_gmt,
+		array $items
+	) {
+		$this->user_id          = $user_id;
+		$this->slug             = $slug;
+		$this->date_created_gmt = $date_created_gmt;
+		$this->items            = $items;
+	}
+
+	/**
+	 * Load a list by slug. Returns false for any other list that doesn't exist.
+	 *
+	 * @param string   $slug List identifier.
+	 * @param int|null $user_id Defaults to the current user.
+	 * @return self|false
+	 */
+	public static function get_by_slug( string $slug, ?int $user_id = null ) {
+		// Gate disabled or unknown slugs upfront so previously-persisted lists
+		// don't bypass the feature flag (the Store API surfaces this as 404).
+		if ( ! wc_get_container()->get( ShopperListsController::class )->is_enabled( $slug ) ) {
+			return false;
+		}
+
+		$user_id = absint( $user_id ? $user_id : get_current_user_id() );
+		if ( ! $user_id ) {
+			return false;
+		}
+
+		$stored = Users::get_site_user_meta( $user_id, self::META_KEY_PREFIX . $slug );
+
+		if ( is_array( $stored ) ) {
+			return self::from_array( $stored, $user_id );
+		}
+
+		// In-memory list; saved on the first save().
+		return new self(
+			$user_id,
+			$slug,
+			current_time( 'mysql', true ),
+			array()
+		);
+	}
+
+	/**
+	 * Get all of the user's lists.
+	 *
+	 * @param int|null $user_id Defaults to the current user.
+	 * @return array<string, self>
+	 */
+	public static function get_all_for_user( ?int $user_id = null ): array {
+		$result = array();
+		foreach ( wc_get_container()->get( ShopperListsController::class )->get_enabled_slugs() as $slug ) {
+			$list = self::get_by_slug( $slug, $user_id );
+			if ( $list ) {
+				$result[ $slug ] = $list;
+			}
+		}
+		return $result;
+	}
+
+	/**
+	 * The list slug (e.g. 'saved-for-later').
+	 */
+	public function get_slug(): string {
+		return $this->slug;
+	}
+
+	/**
+	 * Creation time as a MySQL DATETIME in GMT.
+	 */
+	public function get_date_created_gmt(): string {
+		return $this->date_created_gmt;
+	}
+
+	/**
+	 * Add an item, or merge quantities if it already exists.
+	 *
+	 * @param ShopperListItem $item Item to add.
+	 */
+	public function add_item( ShopperListItem $item ): void {
+		$key = $item->get_key();
+
+		if ( isset( $this->items[ $key ] ) ) {
+			$this->items[ $key ] = ShopperListItem::from_array(
+				array_merge(
+					$this->items[ $key ]->to_array(),
+					array( 'quantity' => $this->items[ $key ]->get_quantity() + $item->get_quantity() )
+				)
+			);
+			return;
+		}
+
+		$this->items[ $key ] = $item;
+	}
+
+	/**
+	 * Remove an item by key. Returns false if the key wasn't present.
+	 *
+	 * @param string $key Storage key of the item to remove.
+	 */
+	public function remove_item( string $key ): bool {
+		if ( ! isset( $this->items[ $key ] ) ) {
+			return false;
+		}
+		unset( $this->items[ $key ] );
+		return true;
+	}
+
+	/**
+	 * Get all items currently in the list.
+	 *
+	 * @return array<string, ShopperListItem>
+	 */
+	public function get_items(): array {
+		return $this->items;
+	}
+
+	/**
+	 * Find an item by key.
+	 *
+	 * @param string $key Storage key.
+	 */
+	public function find_item( string $key ): ?ShopperListItem {
+		return $this->items[ $key ] ?? null;
+	}
+
+	/**
+	 * Persist the current state to user meta.
+	 */
+	public function save(): void {
+		Users::update_site_user_meta(
+			$this->user_id,
+			self::META_KEY_PREFIX . $this->slug,
+			$this->to_array()
+		);
+	}
+
+	/**
+	 * Storage / response shape.
+	 */
+	public function to_array(): array {
+		$items_array = array();
+		foreach ( $this->items as $key => $item ) {
+			$items_array[ $key ] = $item->to_array();
+		}
+
+		return array(
+			'slug'             => $this->slug,
+			'date_created_gmt' => $this->date_created_gmt,
+			'items'            => $items_array,
+		);
+	}
+
+	/**
+	 * Build a ShopperList from a stored array.
+	 *
+	 * @param array $data    Stored list record.
+	 * @param int   $user_id Owning user ID.
+	 */
+	private static function from_array( array $data, int $user_id ): self {
+		$items = array();
+		if ( ! empty( $data['items'] ) && is_array( $data['items'] ) ) {
+			foreach ( $data['items'] as $key => $item_data ) {
+				if ( ! is_array( $item_data ) ) {
+					continue;
+				}
+
+				try {
+					$items[ (string) $key ] = ShopperListItem::from_array( $item_data );
+				} catch ( \Throwable $e ) {
+					continue;
+				}
+			}
+		}
+
+		return new self(
+			$user_id,
+			$data['slug'] ?? '',
+			$data['date_created_gmt'] ?? current_time( 'mysql', true ),
+			$items
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
new file mode 100644
index 00000000000..139a05d8d62
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
@@ -0,0 +1,404 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Enums\ProductType;
+
+/**
+ * A single saved item within a shopper list.
+ */
+class ShopperListItem {
+	/**
+	 * Storage key (md5 of identity tuple).
+	 *
+	 * @var string
+	 */
+	private $key;
+
+	/**
+	 * Product ID at the time the item was saved.
+	 *
+	 * @var int
+	 */
+	private $product_id;
+
+	/**
+	 * Variation ID at the time the item was saved (0 for non-variable products).
+	 *
+	 * @var int
+	 */
+	private $variation_id;
+
+	/**
+	 * Variation attributes captured at save time.
+	 *
+	 * @var array
+	 */
+	private $variation;
+
+	/**
+	 * Saved quantity (always 1 in the current contract).
+	 *
+	 * @var int
+	 */
+	private $quantity;
+
+	/**
+	 * MySQL DATETIME the item was saved, in GMT.
+	 *
+	 * @var string
+	 */
+	private $date_added_gmt;
+
+	/**
+	 * Snapshot of the product title at save time.
+	 *
+	 * @var string
+	 */
+	private $product_title_at_save;
+
+	/**
+	 * Resolved product, cached on the instance.
+	 *
+	 * @var \WC_Product|null
+	 */
+	private $product = null;
+
+	/**
+	 * Private constructor. Use the static factories to obtain concrete instances.
+	 *
+	 * @param string $key                   Storage key (md5 of identity tuple).
+	 * @param int    $product_id            Product ID.
+	 * @param int    $variation_id          Variation ID, or 0.
+	 * @param array  $variation             Variation attributes.
+	 * @param int    $quantity              Saved quantity.
+	 * @param string $date_added_gmt        MySQL DATETIME, GMT.
+	 * @param string $product_title_at_save Title snapshot.
+	 */
+	private function __construct(
+		string $key,
+		int $product_id,
+		int $variation_id,
+		array $variation,
+		int $quantity,
+		string $date_added_gmt,
+		string $product_title_at_save
+	) {
+		$this->key                   = $key;
+		$this->product_id            = $product_id;
+		$this->variation_id          = $variation_id;
+		$this->variation             = $variation;
+		$this->quantity              = $quantity;
+		$this->date_added_gmt        = $date_added_gmt;
+		$this->product_title_at_save = $product_title_at_save;
+	}
+
+	/**
+	 * Construct from a stored item array (from user_meta).
+	 *
+	 * @throws \Exception When the stored payload is missing required fields.
+	 *
+	 * @param array $data Stored item record.
+	 */
+	public static function from_array( array $data ): self {
+		if (
+			empty( $data['key'] ) || ! is_string( $data['key'] )
+			|| empty( $data['product_id'] ) || ! is_int( $data['product_id'] )
+			|| empty( $data['quantity'] ) || ! is_int( $data['quantity'] )
+		) {
+			throw new \Exception( 'Shopper list item requires "key" (string), "product_id" (int), and "quantity" (int).' );
+		}
+
+		return new self(
+			$data['key'],
+			absint( $data['product_id'] ),
+			absint( $data['variation_id'] ?? 0 ),
+			$data['variation'] ?? array(),
+			absint( $data['quantity'] ),
+			$data['date_added_gmt'] ?? current_time( 'mysql', true ),
+			$data['product_title_at_save'] ?? ''
+		);
+	}
+
+	/**
+	 * Construct from a product (or variation) ID and optional payload fields.
+	 *
+	 * @throws \InvalidArgumentException When the provided variation attributes do not match the variation product.
+	 *
+	 * @param int   $product_or_variation_id Product or variation ID.
+	 * @param array $variation               Variation attributes keyed by attribute name.
+	 * @param int   $quantity                Saved quantity. Coerced to a minimum of 1.
+	 * @return self|null Null if the underlying product can't be resolved.
+	 */
+	public static function from_product( int $product_or_variation_id, array $variation = array(), int $quantity = 1 ): ?self {
+		$product = wc_get_product( absint( $product_or_variation_id ) );
+		if ( ! $product ) {
+			return null;
+		}
+
+		if ( $product->is_type( ProductType::VARIATION ) ) {
+			$variation_id = $product->get_id();
+			$product_id   = $product->get_parent_id();
+			$variation    = self::resolve_variation_attributes( $product, $variation );
+		} elseif ( $product->is_type( ProductType::VARIABLE ) ) {
+			throw new \InvalidArgumentException(
+				esc_html__( 'When saving a variation, product_id must be the variation ID, not the parent product ID.', 'woocommerce' )
+			);
+		} else {
+			$product_id   = $product->get_id();
+			$variation_id = 0;
+			$variation    = array();
+		}
+
+		return new self(
+			self::generate_key( $product_id, $variation_id, $variation ),
+			$product_id,
+			$variation_id,
+			$variation,
+			max( 1, $quantity ),
+			current_time( 'mysql', true ),
+			$product->get_title()
+		);
+	}
+
+	/**
+	 * Resolve and validate the variation attribute array against the variation product.
+	 *
+	 * Mirrors {@see CartController::parse_variation_data()}: specific values come from
+	 * the variation (server-authoritative); "any" slots must be supplied by the caller
+	 * with a value that exists on the parent product.
+	 *
+	 * @throws \InvalidArgumentException When the supplied variation attributes are
+	 *                                   missing required values or don't match the
+	 *                                   variation product.
+	 *
+	 * @param \WC_Product $variation_product     Variation product.
+	 * @param array       $requested_attributes  Variation attributes supplied by the caller, keyed by `attribute_<slug>`.
+	 * @return array
+	 */
+	private static function resolve_variation_attributes( \WC_Product $variation_product, array $requested_attributes ): array {
+		$parent = wc_get_product( $variation_product->get_parent_id() );
+		if ( ! $parent || ! $parent->is_type( ProductType::VARIABLE ) || ! $variation_product->is_type( ProductType::VARIATION ) ) {
+			return array();
+		}
+
+		$result = array();
+
+		$all_attributes       = array_filter( $parent->get_attributes(), fn( $attribute ) => $attribute->get_variation() );
+		$variation_attributes = wc_get_product_variation_attributes( $variation_product->get_id() );
+
+		foreach ( $all_attributes as $name => $attribute ) {
+			$key      = 'attribute_' . $name;
+			$expected = $variation_attributes[ $key ] ?? '';
+
+			// Variation doesn't provide attribute ('any' attribute).
+			if ( '' === $expected ) {
+				if ( ! isset( $requested_attributes[ $key ] ) ) {
+					throw new \InvalidArgumentException(
+						esc_html(
+							sprintf(
+								/* translators: %s: attribute name. */
+								__( 'Attribute "%s" is required.', 'woocommerce' ),
+								$name
+							)
+						)
+					);
+				}
+
+				if ( ! in_array( $requested_attributes[ $key ], $attribute->get_slugs(), true ) ) {
+					throw new \InvalidArgumentException(
+						esc_html(
+							sprintf(
+								/* translators: 1: attribute name, 2: comma-separated allowed values. */
+								__( 'Invalid value posted for "%1$s". Allowed values: %2$s', 'woocommerce' ),
+								$name,
+								implode( ', ', $attribute->get_slugs() )
+							)
+						)
+					);
+				}
+
+				$result[ $key ] = $requested_attributes[ $key ];
+				continue;
+			}//end if
+
+			// Variation provides attribute.
+			if ( isset( $requested_attributes[ $key ] ) && $requested_attributes[ $key ] !== $expected ) {
+				throw new \InvalidArgumentException(
+					esc_html(
+						sprintf(
+							/* translators: 1: attribute name, 2: expected value. */
+							__( 'Invalid value posted for "%1$s". Expected "%2$s".', 'woocommerce' ),
+							$name,
+							$expected
+						)
+					)
+				);
+			}
+
+			$result[ $key ] = $expected;
+		}//end foreach
+
+		return $result;
+	}
+
+	/**
+	 * Storage key — also used as the response identifier.
+	 */
+	public function get_key(): string {
+		return $this->key;
+	}
+
+	/**
+	 * Product ID at save time.
+	 */
+	public function get_product_id(): int {
+		return $this->product_id;
+	}
+
+	/**
+	 * Variation ID at save time, or 0 for non-variable products.
+	 */
+	public function get_variation_id(): int {
+		return $this->variation_id;
+	}
+
+	/**
+	 * Saved quantity.
+	 */
+	public function get_quantity(): int {
+		return $this->quantity;
+	}
+
+	/**
+	 * Variation attributes captured at save time.
+	 */
+	public function get_variation_attributes(): array {
+		return $this->variation;
+	}
+
+	/**
+	 * Save time as a MySQL DATETIME in GMT.
+	 */
+	public function get_date_added_gmt(): string {
+		return $this->date_added_gmt;
+	}
+
+	/**
+	 * Snapshot of the product title at save time.
+	 */
+	public function get_product_title_at_save(): string {
+		return $this->product_title_at_save;
+	}
+
+	/**
+	 * Resolve the live product (or variation) backing this saved item.
+	 */
+	public function get_product(): ?\WC_Product {
+		if ( $this->product instanceof \WC_Product ) {
+			return $this->product;
+		}
+		$id            = $this->variation_id > 0 ? $this->variation_id : $this->product_id;
+		$product       = $id > 0 ? wc_get_product( $id ) : false;
+		$this->product = $product instanceof \WC_Product ? $product : null;
+		return $this->product;
+	}
+
+	/**
+	 * Whether the row serves live product data. True when the product (and its
+	 * parent, for variations) is `publish`; password-gated products still
+	 * qualify since their page renders behind a prompt.
+	 */
+	public function is_live(): bool {
+		$product = $this->get_product();
+		if ( ! $product instanceof \WC_Product || ProductStatus::PUBLISH !== $product->get_status() ) {
+			return false;
+		}
+
+		$parent_id = $product->get_parent_id();
+		if ( $parent_id > 0 ) {
+			$parent = wc_get_product( $parent_id );
+
+			if ( ! $parent instanceof \WC_Product || ProductStatus::PUBLISH !== $parent->get_status() ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Whether the product can be added to the cart. Mirrors the catalog gate
+	 * (`is_purchasable()` && `is_in_stock()`), but additionally requires the
+	 * row to be live and rejects password-gated products (self or parent) —
+	 * cart-add can't prompt for a password.
+	 */
+	public function is_purchasable(): bool {
+		$product = $this->get_product();
+		if ( ! $this->is_live() || ! $product ) {
+			return false;
+		}
+		if ( ! $product->is_purchasable() || ! $product->is_in_stock() ) {
+			return false;
+		}
+
+		if ( ! empty( $product->get_post_password() ) ) {
+			return false;
+		}
+
+		$parent_id = $product->get_parent_id();
+		if ( $parent_id > 0 ) {
+			$parent = wc_get_product( $parent_id );
+			if ( $parent instanceof \WC_Product && ! empty( $parent->get_post_password() ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Storage shape used to persist into user_meta.
+	 */
+	public function to_array(): array {
+		return array(
+			'key'                   => $this->key,
+			'product_id'            => $this->product_id,
+			'variation_id'          => $this->variation_id,
+			'variation'             => $this->variation,
+			'quantity'              => $this->quantity,
+			'date_added_gmt'        => $this->date_added_gmt,
+			'product_title_at_save' => $this->product_title_at_save,
+		);
+	}
+
+	/**
+	 * Compute a deterministic item key. Mirrors WC_Cart::generate_cart_id() so the same
+	 * product+variation always hashes to the same key, regardless of the input key order
+	 * for variation attributes.
+	 *
+	 * @param int   $product_id   Product ID.
+	 * @param int   $variation_id Variation ID, or 0.
+	 * @param array $variation    Variation attributes.
+	 */
+	private static function generate_key( int $product_id, int $variation_id, array $variation ): string {
+		$id_parts = array( $product_id );
+
+		if ( $variation_id ) {
+			$id_parts[] = $variation_id;
+		}
+
+		if ( ! empty( $variation ) ) {
+			ksort( $variation );
+			$variation_key = '';
+			foreach ( $variation as $k => $v ) {
+				$variation_key .= trim( (string) $k ) . trim( (string) $v );
+			}
+			$id_parts[] = $variation_key;
+		}
+
+		return md5( implode( '_', $id_parts ) );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php
new file mode 100644
index 00000000000..a41a6549afa
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php
@@ -0,0 +1,331 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ShopperLists;
+
+/**
+ * Shared markup helpers for blocks that render a shopper-list item card
+ * (Saved for Later, Wishlist, …). Static helpers, not an abstract base —
+ * the two blocks' lifecycles diverge enough (auto-injected vs merchant-
+ * placed, different actions, different empty-state gating) that inheritance
+ * is not a clean fit. Consumers stitch the fragments together with their
+ * own quantity / action button / heading bits.
+ *
+ * Any change here is co-reviewed with every consuming block — drift in the
+ * shared row shape will break first paint for whoever didn't get the memo.
+ */
+final class ShopperListRenderer {
+
+	/**
+	 * Shared CSS root class for the row. Each section helper outputs
+	 * BEM-style modifiers off this base (`__image-slot`, `__remove`, …).
+	 */
+	public const ROW_CLASS = 'wc-block-shopper-list-item';
+
+	/**
+	 * Wrap `$inner` in the block's outer `<section><ul>…</ul></section>`
+	 * grid scaffold. `$wrapper_attrs` are merged with the block's wrapper
+	 * attributes via `get_block_wrapper_attributes()`.
+	 *
+	 * Trust contract: callers are responsible for ensuring `$inner` and
+	 * `$before_list` contain only safe, escaped HTML — typically composed
+	 * from the section helpers below, never from raw schema/request input.
+	 *
+	 * @param array<string, mixed> $wrapper_attrs Attributes for the outer `<section>`.
+	 * @param string               $list_class    Class attribute for the inner `<ul>`.
+	 * @param string               $inner         Markup placed inside the `<ul>` (template + SSR rows + empty state).
+	 * @param string               $before_list   Markup placed between `<section>` and `<ul>` (header, notices region).
+	 * @return string
+	 */
+	public static function render_grid_wrapper( array $wrapper_attrs, string $list_class, string $inner, string $before_list = '' ): string {
+		return sprintf(
+			'<section %1$s>%2$s<ul class="%3$s">%4$s</ul></section>',
+			get_block_wrapper_attributes( $wrapper_attrs ),
+			$before_list,
+			esc_attr( $list_class ),
+			$inner
+		);
+	}
+
+	/**
+	 * Wrap `$row_inner_markup` in a `<template data-wp-each>` element that
+	 * iAPI uses to render new rows. `$row_inner_markup` is the inner HTML
+	 * for the `<li>` — everything between `<li>` and `</li>`.
+	 *
+	 * Trust contract: caller is responsible for ensuring `$row_inner_markup`
+	 * contains only safe, escaped HTML.
+	 *
+	 * @param string $row_inner_markup Inner markup for the `<li>`.
+	 * @return string
+	 */
+	public static function render_each_template( string $row_inner_markup ): string {
+		return sprintf(
+			'<template data-wp-each--list-item="state.currentItems" data-wp-each-key="context.listItem.key"><li class="%1$s">%2$s</li></template>',
+			esc_attr( self::ROW_CLASS ),
+			$row_inner_markup
+		);
+	}
+
+	/**
+	 * Wrap `$row_inner_markup` in an SSR `<li data-wp-each-child>` element
+	 * seeded with the per-row iAPI context derived from `$item`. iAPI's
+	 * hydration treats this as a no-op diff against the `<template>` if
+	 * the inner markup matches.
+	 *
+	 * Trust contract: caller is responsible for ensuring `$row_inner_markup`
+	 * contains only safe, escaped HTML.
+	 *
+	 * @param array<string, mixed> $item             Schema-shape item.
+	 * @param string               $row_inner_markup Inner markup for the `<li>`.
+	 * @return string
+	 */
+	public static function render_each_child( array $item, string $row_inner_markup ): string {
+		$context = array( 'listItem' => $item );
+		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_interactivity_data_wp_context() returns a safely-encoded attribute pair; $row_inner_markup is composed of escaped fragments from the section helpers below.
+		return sprintf(
+			'<li class="%1$s" data-wp-each-child %2$s>%3$s</li>',
+			esc_attr( self::ROW_CLASS ),
+			wp_interactivity_data_wp_context( $context ),
+			$row_inner_markup
+		);
+	}
+
+	/**
+	 * Render the image + title + price triplet for the template-mode row
+	 * (no static attrs; bindings only). Identical between consumer blocks.
+	 *
+	 * @return string
+	 */
+	public static function render_template_common_row(): string {
+		ob_start();
+		?>
+		<div class="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
+			<a data-wp-bind--href="context.listItem.permalink">
+				<span class="<?php echo esc_attr( self::ROW_CLASS ); ?>__image-slot" data-wp-context='{"htmlField":"image_html"}' data-wp-watch="callbacks.updateInnerHtml"></span>
+			</a>
+			<button
+				type="button"
+				class="<?php echo esc_attr( self::ROW_CLASS ); ?>__remove"
+				data-wp-on--click="actions.onClickRemove"
+				data-wp-bind--aria-label="state.currentItemRemoveLabel"
+				data-wp-bind--disabled="state.isCurrentItemPending"
+			>
+				<?php echo self::get_remove_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
+			</button>
+			<span class="<?php echo esc_attr( self::ROW_CLASS ); ?>__variation" data-wp-bind--hidden="!state.currentItemVariationLabel" data-wp-text="state.currentItemVariationLabel"></span>
+		</div>
+		<h2 class="wp-block-post-title has-text-align-center has-medium-font-size">
+			<a data-wp-bind--href="context.listItem.permalink" data-wp-text="state.currentItemDisplayName"></a>
+		</h2>
+		<div class="price wc-block-components-product-price has-text-align-center has-small-font-size" data-wp-bind--hidden="state.isPriceHidden" data-wp-context='{"htmlField":"price_html"}' data-wp-watch="callbacks.updateInnerHtml"></div>
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Render the image + title + price triplet for the SSR-mode row, with
+	 * values populated from `$item` and `$remove_aria_label_template`. The
+	 * binding directives match the template-mode markup so iAPI's hydration
+	 * is a no-op diff after first paint.
+	 *
+	 * @param array<string, mixed> $item                        Schema-shape item.
+	 * @param string               $remove_aria_label_template  Sprintf template for the remove button's aria-label. `%s` is replaced with the product name.
+	 * @return string
+	 */
+	public static function render_ssr_common_row( array $item, string $remove_aria_label_template ): string {
+		$is_live         = ! empty( $item['is_live'] );
+		$name            = (string) ( $item['name'] ?? '' );
+		$permalink       = (string) ( $item['permalink'] ?? '' );
+		$alt             = html_entity_decode( $name, ENT_QUOTES, 'UTF-8' );
+		$image_html      = (string) ( $item['image_html'] ?? '' );
+		$price_html      = (string) ( $item['price_html'] ?? '' );
+		$variation_label = self::get_variation_label( $item );
+		$remove_aria     = sprintf( $remove_aria_label_template, $alt );
+		$is_price_hidden = '' === $price_html;
+		// Tombstone rows (`is_live=false` or empty permalink) render `<a>`
+		// without an href — keeps the element shape stable for iAPI
+		// reconciliation against the live-row template, and the CSS in the
+		// shared partial drops link affordances when the anchor has no href.
+		$href_attr = $is_live && '' !== $permalink ? 'href="' . esc_url( $permalink ) . '"' : '';
+
+		ob_start();
+		?>
+		<div class="wc-block-components-product-image wc-block-components-product-image--aspect-ratio-auto">
+			<a <?php echo $href_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above with esc_url(). ?> data-wp-bind--href="context.listItem.permalink">
+				<span
+					class="<?php echo esc_attr( self::ROW_CLASS ); ?>__image-slot"
+					data-wp-context='{"htmlField":"image_html"}'
+					data-wp-watch="callbacks.updateInnerHtml"
+				>
+					<?php echo wp_kses_post( $image_html ); ?>
+				</span>
+			</a>
+			<button
+				type="button"
+				class="<?php echo esc_attr( self::ROW_CLASS ); ?>__remove"
+				aria-label="<?php echo esc_attr( $remove_aria ); ?>"
+				data-wp-on--click="actions.onClickRemove"
+				data-wp-bind--aria-label="state.currentItemRemoveLabel"
+				data-wp-bind--disabled="state.isCurrentItemPending"
+			>
+				<?php echo self::get_remove_icon_svg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG markup. ?>
+			</button>
+			<span
+				class="<?php echo esc_attr( self::ROW_CLASS ); ?>__variation"
+				data-wp-bind--hidden="!state.currentItemVariationLabel"
+				data-wp-text="state.currentItemVariationLabel"
+				<?php
+				if ( '' === $variation_label ) {
+					echo 'hidden';
+				}
+				?>
+			><?php echo esc_html( $variation_label ); ?></span>
+		</div>
+		<h2 class="wp-block-post-title has-text-align-center has-medium-font-size">
+			<a <?php echo $href_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above with esc_url(). ?> data-wp-bind--href="context.listItem.permalink" data-wp-text="state.currentItemDisplayName"><?php echo esc_html( $alt ); ?></a>
+		</h2>
+		<div
+			class="price wc-block-components-product-price has-text-align-center has-small-font-size"
+			data-wp-bind--hidden="state.isPriceHidden"
+			data-wp-context='{"htmlField":"price_html"}'
+			data-wp-watch="callbacks.updateInnerHtml"
+			<?php
+			if ( $is_price_hidden ) {
+				echo 'hidden';
+			}
+			?>
+		>
+			<?php echo wp_kses_post( $price_html ); ?>
+		</div>
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Empty-state `<li>` that the block toggles on once `state.isEmpty`
+	 * flips. `$start_hidden = true` makes SSR ship with `hidden` so the
+	 * message doesn't flash for shoppers whose list is being populated
+	 * client-side. `$start_hidden = false` is for blocks (e.g. Wishlist)
+	 * where the message should show on first paint when the list is empty.
+	 *
+	 * @param string $message      Visible empty-state message.
+	 * @param string $css_class    Class attribute for the `<li>`.
+	 * @param bool   $start_hidden Whether the `<li>` should be `hidden` on first paint.
+	 * @return string
+	 */
+	public static function render_empty_state( string $message, string $css_class, bool $start_hidden = true ): string {
+		return sprintf(
+			'<li class="%1$s" data-wp-bind--hidden="!state.isEmpty"%2$s>%3$s</li>',
+			esc_attr( $css_class ),
+			$start_hidden ? ' hidden' : '',
+			esc_html( $message )
+		);
+	}
+
+	/**
+	 * Render the iAPI store-notices region used by the row-level error
+	 * banners. Mirrors `AddToCartWithOptions::render_interactivity_notices_region()`
+	 * — keep in sync if the shape changes.
+	 *
+	 * @param string $wrapper_class Class attribute for the outer `<div>`.
+	 * @return string
+	 */
+	public static function render_interactivity_notices_region( string $wrapper_class ): string {
+		ob_start();
+		?>
+		<div class="<?php echo esc_attr( $wrapper_class ); ?> wc-block-components-notices" data-wp-interactive="woocommerce/store-notices" data-wp-bind--hidden="!context.notices.length" hidden>
+			<template data-wp-each--notice="context.notices" data-wp-each-key="context.notice.id">
+				<div
+					class="wc-block-components-notice-banner"
+					data-wp-class--is-error="state.isError"
+					data-wp-class--is-success="state.isSuccess"
+					data-wp-class--is-info="state.isInfo"
+					data-wp-class--is-dismissible="context.notice.dismissible"
+					data-wp-bind--role="state.role"
+					data-wp-watch="callbacks.injectIcon"
+				>
+					<div class="wc-block-components-notice-banner__content">
+						<span data-wp-init="callbacks.renderNoticeContent" aria-live="assertive" aria-atomic="true"></span>
+					</div>
+					<button
+						type="button"
+						data-wp-bind--hidden="!context.notice.dismissible"
+						class="wc-block-components-button wp-element-button wc-block-components-notice-banner__dismiss contained"
+						aria-label="<?php esc_attr_e( 'Dismiss this notice', 'woocommerce' ); ?>"
+						data-wp-on--click="actions.removeNotice"
+					>
+						<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+							<path d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" />
+						</svg>
+					</button>
+				</div>
+			</template>
+		</div>
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Markup for the trash icon used in the remove-item button. Mirrors the
+	 * `trash` icon from `@wordpress/icons` that the cart line item uses for
+	 * `wc-block-cart-item__remove-link`, inlined here so SSR first paint
+	 * matches what JS would render after hydration. `currentColor` lets the
+	 * surrounding badge wrapper drive the fill.
+	 *
+	 * @return string
+	 */
+	public static function get_remove_icon_svg(): string {
+		return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 5.5A2.25 2.25 0 0 0 9.878 7h4.244A2.251 2.251 0 0 0 12 5.5ZM12 4a3.751 3.751 0 0 0-3.675 3H5v1.5h1.27l.818 8.997a2.75 2.75 0 0 0 2.739 2.501h4.347a2.75 2.75 0 0 0 2.738-2.5L17.73 8.5H19V7h-3.325A3.751 3.751 0 0 0 12 4Zm4.224 4.5H7.776l.806 8.861a1.25 1.25 0 0 0 1.245 1.137h4.347a1.25 1.25 0 0 0 1.245-1.137l.805-8.861Z"/></svg>';
+	}
+
+	/**
+	 * Markup for the empty-star icon. Mirrors `starEmpty` from
+	 * `@wordpress/icons`, inlined here so SSR first paint matches what JS
+	 * renders after hydration. `currentColor` lets the surrounding button
+	 * drive the fill.
+	 *
+	 * @return string
+	 */
+	public static function get_star_empty_svg(): string {
+		return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M9.706 8.646a.25.25 0 01-.188.137l-4.626.672a.25.25 0 00-.139.427l3.348 3.262a.25.25 0 01.072.222l-.79 4.607a.25.25 0 00.362.264l4.138-2.176a.25.25 0 01.233 0l4.137 2.175a.25.25 0 00.363-.263l-.79-4.607a.25.25 0 01.072-.222l3.347-3.262a.25.25 0 00-.139-.427l-4.626-.672a.25.25 0 01-.188-.137l-2.069-4.192a.25.25 0 00-.448 0L9.706 8.646zM12 7.39l-.948 1.921a1.75 1.75 0 01-1.317.957l-2.12.308 1.534 1.495c.412.402.6.982.503 1.55l-.362 2.11 1.896-.997a1.75 1.75 0 011.629 0l1.895.997-.362-2.11a1.75 1.75 0 01.504-1.55l1.533-1.495-2.12-.308a1.75 1.75 0 01-1.317-.957L12 7.39z"/></svg>';
+	}
+
+	/**
+	 * Markup for the filled-star icon. Mirrors `starFilled` from
+	 * `@wordpress/icons`, inlined here so SSR first paint matches what JS
+	 * renders after hydration. `currentColor` lets the surrounding button
+	 * drive the fill.
+	 *
+	 * @return string
+	 */
+	public static function get_star_filled_svg(): string {
+		return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M11.776 4.454a.25.25 0 01.448 0l2.069 4.192a.25.25 0 00.188.137l4.626.672a.25.25 0 01.139.426l-3.348 3.263a.25.25 0 00-.072.222l.79 4.607a.25.25 0 01-.362.263l-4.138-2.175a.25.25 0 00-.232 0l-4.138 2.175a.25.25 0 01-.363-.263l.79-4.607a.25.25 0 00-.071-.222L4.754 9.881a.25.25 0 01.139-.426l4.626-.672a.25.25 0 00.188-.137l2.069-4.192z"/></svg>';
+	}
+
+	/**
+	 * Build a comma-separated variation label like "Color: Blue, Size: M".
+	 *
+	 * @param array<string, mixed> $item Schema-shape item.
+	 * @return string
+	 */
+	public static function get_variation_label( array $item ): string {
+		$variation = $item['variation'] ?? array();
+		if ( ! is_array( $variation ) || empty( $variation ) ) {
+			return '';
+		}
+		$parts = array();
+		foreach ( $variation as $entry ) {
+			if ( ! is_array( $entry ) ) {
+				continue;
+			}
+			$attribute = isset( $entry['attribute'] ) ? html_entity_decode( (string) $entry['attribute'], ENT_QUOTES, 'UTF-8' ) : '';
+			$value     = isset( $entry['value'] ) ? html_entity_decode( (string) $entry['value'], ENT_QUOTES, 'UTF-8' ) : '';
+			if ( '' === $attribute && '' === $value ) {
+				continue;
+			}
+			$parts[] = $attribute . ': ' . $value;
+		}
+		return implode( ', ', $parts );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
new file mode 100644
index 00000000000..18e403a55ee
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
@@ -0,0 +1,160 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+/**
+ * Tracks which shopper-list types are turned on and registers the
+ * user-facing pieces that depend on each.
+ *
+ * @internal Just for internal use.
+ */
+final class ShopperListsController implements RegisterHooksInterface {
+
+	/**
+	 * Known list slugs and the feature flag that controls each.
+	 */
+	private const SUPPORTED_LISTS = array(
+		'saved-for-later' => 'cart_save_for_later',
+		'wishlist'        => 'product_wishlist',
+	);
+
+	/**
+	 * Wishlist My Account endpoint slug. Wrapped in a method (rather than
+	 * a constant) so a future filter or settings hook can override it
+	 * without touching every call site.
+	 */
+	public function get_wishlist_endpoint(): string {
+		return 'wishlist';
+	}
+
+	/**
+	 * Whether a given list type is on, or whether any list type is on
+	 * when no slug is passed.
+	 *
+	 * @param string|null $list_slug List slug, or null to ask about any type.
+	 */
+	public function is_enabled( ?string $list_slug = null ): bool {
+		if ( null === $list_slug ) {
+			foreach ( self::SUPPORTED_LISTS as $feature ) {
+				if ( FeaturesUtil::feature_is_enabled( $feature ) ) {
+					return true;
+				}
+			}
+			return false;
+		}
+		$feature = self::SUPPORTED_LISTS[ $list_slug ] ?? null;
+		return null !== $feature && FeaturesUtil::feature_is_enabled( $feature );
+	}
+
+	/**
+	 * Slugs of all currently-enabled lists, in declaration order.
+	 *
+	 * @return string[]
+	 */
+	public function get_enabled_slugs(): array {
+		return array_keys(
+			array_filter(
+				self::SUPPORTED_LISTS,
+				static fn( string $feature ): bool => FeaturesUtil::feature_is_enabled( $feature )
+			)
+		);
+	}
+
+	/**
+	 * Register hooks.
+	 */
+	public function register(): void {
+		add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'maybe_flush_rewrite_rules' ), 10, 1 );
+		add_action( 'init', array( $this, 'maybe_register_wishlist_endpoint' ), 5 );
+	}
+
+	/**
+	 * Register the wishlist endpoint.
+	 */
+	public function maybe_register_wishlist_endpoint(): void {
+		if ( ! $this->is_enabled( 'wishlist' ) ) {
+			return;
+		}
+
+		$endpoint = $this->get_wishlist_endpoint();
+		add_filter( 'woocommerce_get_query_vars', array( $this, 'add_wishlist_query_var' ) );
+		add_filter( 'woocommerce_account_menu_items', array( $this, 'add_wishlist_menu_item' ) );
+		add_filter( 'woocommerce_endpoint_' . $endpoint . '_title', array( $this, 'wishlist_endpoint_title' ) );
+		add_action( 'woocommerce_account_' . $endpoint . '_endpoint', array( $this, 'render_wishlist_endpoint' ) );
+	}
+
+	/**
+	 * Flush rewrite rules when the wishlist feature is turned on or off.
+	 *
+	 * @param string $feature_id The feature that changed.
+	 */
+	public function maybe_flush_rewrite_rules( string $feature_id ): void {
+		if ( 'product_wishlist' === $feature_id ) {
+			update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+		}
+	}
+
+	/**
+	 * Register the `wishlist` query var.
+	 *
+	 * @param array $vars Existing query vars keyed by slug.
+	 */
+	public function add_wishlist_query_var( $vars ): array {
+		if ( ! is_array( $vars ) ) {
+			return array();
+		}
+
+		$endpoint          = $this->get_wishlist_endpoint();
+		$vars[ $endpoint ] = $endpoint;
+		return $vars;
+	}
+
+	/**
+	 * Insert the Wishlist link just before the logout link.
+	 *
+	 * @param array $items Existing menu items keyed by slug.
+	 */
+	public function add_wishlist_menu_item( $items ): array {
+		if ( ! is_array( $items ) ) {
+			return array();
+		}
+
+		$wishlist_endpoint = $this->get_wishlist_endpoint();
+		$wishlist_label    = __( 'Wishlist', 'woocommerce' );
+
+		// Insert the wishlist item before the logout item, or at the end if not present.
+		$logout_pos = array_search( 'customer-logout', array_keys( $items ), true );
+		if ( false === $logout_pos ) {
+			$items[ $wishlist_endpoint ] = $wishlist_label;
+		} else {
+			$items = array_slice( $items, 0, $logout_pos, true )
+				+ array( $wishlist_endpoint => $wishlist_label )
+				+ array_slice( $items, $logout_pos, null, true );
+		}
+		return $items;
+	}
+
+	/**
+	 * Wishlist endpoint page title.
+	 *
+	 * @param string $title Default title.
+	 */
+	public function wishlist_endpoint_title( $title ): string {
+		return __( 'Wishlist', 'woocommerce' );
+	}
+
+	/**
+	 * Render the wishlist endpoint by dispatching to the
+	 * `woocommerce/wishlist` block. The block handles the empty state,
+	 * logged-out guard, asset enqueues, and item rendering.
+	 */
+	public function render_wishlist_endpoint(): void {
+		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the block string is a static literal; `do_blocks()` invokes the registered block's render callback, which is responsible for its own escaping.
+		echo do_blocks( '<!-- wp:woocommerce/wishlist /-->' );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php
new file mode 100644
index 00000000000..ff4985e906e
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php
@@ -0,0 +1,253 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+
+/**
+ * GET / POST on /shopper-lists/{slug}/items.
+ *
+ * GET returns the items in a list.
+ * POST saves an item to the list either from an existing cart line or from direct item payload fields.
+ */
+class ShopperListItems extends AbstractRoute {
+	// Stopgap CSRF guard, replaced once the upstream trait lands on trunk.
+	use ShopperListsNonceCheck;
+
+	/**
+	 * Route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'shopper-list-items';
+
+	/**
+	 * Schema identifier this route uses.
+	 *
+	 * @var string
+	 */
+	const SCHEMA_TYPE = 'shopper-list-item';
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path regex for this REST route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/shopper-lists/(?P<slug>[a-z0-9-]+)/items';
+	}
+
+	/**
+	 * Get method arguments for this REST route.
+	 *
+	 * @return array
+	 */
+	public function get_args() {
+		return array(
+			'args'   => array(
+				'slug' => array(
+					'description' => __( 'Stable slug for the list.', 'woocommerce' ),
+					'type'        => 'string',
+				),
+			),
+			array(
+				'methods'             => \WP_REST_Server::READABLE,
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => function () {
+					return is_user_logged_in();
+				},
+				'args'                => array(
+					'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+				),
+			),
+			array(
+				'methods'             => \WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => function () {
+					return is_user_logged_in();
+				},
+				'args'                => array(
+					'cart_item_key' => array(
+						'description' => __( 'Existing cart item key to copy into the list.', 'woocommerce' ),
+						'type'        => 'string',
+					),
+					'product_id'    => array(
+						'description' => __( 'Product or variation ID to save. Required when cart_item_key is not supplied.', 'woocommerce' ),
+						'type'        => 'integer',
+					),
+					'variation'     => array(
+						'description' => __( 'Chosen attributes (for variations).', 'woocommerce' ),
+						'type'        => 'array',
+						'context'     => array( 'view', 'edit' ),
+						'items'       => array(
+							'type'       => 'object',
+							'properties' => array(
+								'attribute' => array(
+									'description' => __( 'Variation attribute name.', 'woocommerce' ),
+									'type'        => 'string',
+									'context'     => array( 'view', 'edit' ),
+								),
+								'value'     => array(
+									'description' => __( 'Variation attribute value.', 'woocommerce' ),
+									'type'        => 'string',
+									'context'     => array( 'view', 'edit' ),
+								),
+							),
+						),
+					),
+					'quantity'      => array(
+						'description' => __( 'Quantity for the saved item.', 'woocommerce' ),
+						'type'        => 'integer',
+						'default'     => 1,
+					),
+				),
+			),
+			'schema' => array( $this->schema, 'get_public_item_schema' ),
+		);
+	}
+
+	/**
+	 * Return the items in the requested list.
+	 *
+	 * @throws RouteException When the list doesn't exist.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_response( \WP_REST_Request $request ) {
+		$list = ShopperList::get_by_slug( (string) $request['slug'] );
+		if ( ! $list ) {
+			throw new RouteException( 'woocommerce_rest_shopper_list_not_found', esc_html__( 'Your saved list isn\'t available right now.', 'woocommerce' ), 404 );
+		}
+
+		$items = array_values( $list->get_items() );
+		$this->prime_product_caches_for_items( $items );
+
+		$response = array();
+		foreach ( $items as $item ) {
+			$response[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $item, $request ) );
+		}
+
+		return rest_ensure_response( $response );
+	}
+
+	/**
+	 * Add an item to the requested list from cart_item_key or direct product payload fields.
+	 *
+	 * @throws RouteException On validation failure.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_post_response( \WP_REST_Request $request ) {
+		$list = ShopperList::get_by_slug( (string) $request['slug'] );
+
+		if ( ! $list ) {
+			throw new RouteException( 'woocommerce_rest_shopper_list_not_found', esc_html__( 'Your saved list isn\'t available right now.', 'woocommerce' ), 404 );
+		}
+
+		[ $lookup_id, $variation, $quantity ] = $this->resolve_item_payload( $request );
+
+		try {
+			$item = ShopperListItem::from_product( $lookup_id, $variation, $quantity );
+		} catch ( \InvalidArgumentException $e ) {
+			throw new RouteException( 'woocommerce_rest_shopper_list_invalid_variation', esc_html( $e->getMessage() ), 400 );
+		}
+		if ( ! $item ) {
+			throw new RouteException( 'woocommerce_rest_shopper_list_unknown_product', esc_html__( 'No product exists for the supplied item.', 'woocommerce' ), 404 );
+		}
+
+		$list->add_item( $item );
+		$list->save();
+
+		$saved = $list->find_item( $item->get_key() ) ?? $item;
+		return new \WP_REST_Response( $this->schema->get_item_response( $saved ), 201 );
+	}
+
+	/**
+	 * Resolve the POST input into a uniform payload (product lookup id, variation, quantity).
+	 *
+	 * Accepts either an existing cart_item_key, or direct product_id/variation_id/variation.
+	 *
+	 * @throws RouteException When neither a cart_item_key nor a product_id is supplied, or the cart_item_key is unknown.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return array{0:int,1:array,2:int} `[ lookup_id, variation, quantity ]`.
+	 */
+	private function resolve_item_payload( \WP_REST_Request $request ): array {
+		$cart_item_key = (string) $request->get_param( 'cart_item_key' );
+
+		if ( $cart_item_key ) {
+			if ( ! did_action( 'woocommerce_load_cart_from_session' ) || ! wc()->cart ) {
+				wc_load_cart();
+			}
+
+			$cart_contents = wc()->cart->get_cart();
+			if ( empty( $cart_contents[ $cart_item_key ] ) ) {
+				throw new RouteException( 'woocommerce_rest_shopper_list_invalid_cart_item_key', esc_html__( 'No cart item exists for the supplied key.', 'woocommerce' ), 404 );
+			}
+
+			$line            = $cart_contents[ $cart_item_key ];
+			$product_id      = absint( $line['product_id'] ?? 0 );
+			$variation_id    = absint( $line['variation_id'] ?? 0 );
+			$variation_attrs = isset( $line['variation'] ) && is_array( $line['variation'] ) ? $line['variation'] : array();
+
+			return array(
+				$variation_id ? $variation_id : $product_id,
+				$variation_attrs,
+				absint( $line['quantity'] ?? 1 ),
+			);
+		}//end if
+
+		$product_id = absint( $request->get_param( 'product_id' ) );
+		if ( ! $product_id ) {
+			throw new RouteException( 'woocommerce_rest_shopper_list_missing_item_input', esc_html__( 'Provide cart_item_key or product_id.', 'woocommerce' ), 400 );
+		}
+
+		$variation = wp_list_pluck( (array) $request->get_param( 'variation' ), 'value', 'attribute' );
+
+		return array(
+			$product_id,
+			(array) array_combine(
+				array_map( 'wc_variation_attribute_name', array_keys( $variation ) ),
+				array_values( $variation )
+			),
+			absint( $request->get_param( 'quantity' ) ),
+		);
+	}
+
+	/**
+	 * Prime post caches before the per-item product lookup loop in the schema.
+	 *
+	 * @param ShopperListItem[] $items Items.
+	 */
+	private function prime_product_caches_for_items( array $items ): void {
+		$ids = array_map(
+			fn( $item ) => $item->get_variation_id() ? $item->get_variation_id() : $item->get_product_id(),
+			$items
+		);
+
+		_prime_post_caches( array_unique( $ids ) );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php
new file mode 100644
index 00000000000..ab44a9ff839
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php
@@ -0,0 +1,102 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+
+/**
+ * DELETE /shopper-lists/{slug}/items/{key}.
+ */
+class ShopperListItemsByKey extends AbstractRoute {
+	// Stopgap CSRF guard, replaced once the upstream trait lands on trunk.
+	use ShopperListsNonceCheck;
+
+	/**
+	 * Route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'shopper-list-items-by-key';
+
+	/**
+	 * Schema identifier this route uses.
+	 *
+	 * @var string
+	 */
+	const SCHEMA_TYPE = 'shopper-list-item';
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path regex for this REST route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/shopper-lists/(?P<slug>[a-z0-9-]+)/items/(?P<key>[\w-]{32})';
+	}
+
+	/**
+	 * Get method arguments for this REST route.
+	 *
+	 * @return array
+	 */
+	public function get_args() {
+		return array(
+			'args'   => array(
+				'slug' => array(
+					'description' => __( 'Stable slug for the list.', 'woocommerce' ),
+					'type'        => 'string',
+				),
+				'key'  => array(
+					'description' => __( 'Item key.', 'woocommerce' ),
+					'type'        => 'string',
+				),
+			),
+			array(
+				'methods'             => \WP_REST_Server::DELETABLE,
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => function () {
+					return is_user_logged_in();
+				},
+			),
+			'schema' => array( $this->schema, 'get_public_item_schema' ),
+		);
+	}
+
+	/**
+	 * Delete a single item from a list.
+	 *
+	 * @throws RouteException When the list or item doesn't exist.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_delete_response( \WP_REST_Request $request ) {
+		$list = ShopperList::get_by_slug( (string) $request['slug'] );
+
+		if ( ! $list ) {
+			throw new RouteException( 'woocommerce_rest_shopper_list_not_found', esc_html__( 'Your saved list isn\'t available right now.', 'woocommerce' ), 404 );
+		}
+
+		if ( ! $list->remove_item( (string) $request['key'] ) ) {
+			throw new RouteException( 'woocommerce_rest_shopper_list_item_not_found', esc_html__( 'That item isn\'t in your saved list anymore.', 'woocommerce' ), 404 );
+		}
+
+		$list->save();
+
+		return new \WP_REST_Response( null, 204 );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php
new file mode 100644
index 00000000000..18302622d76
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php
@@ -0,0 +1,83 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+
+/**
+ * GET /shopper-lists — collection of the current user's shopper lists.
+ */
+class ShopperLists extends AbstractRoute {
+	/**
+	 * Route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'shopper-lists';
+
+	/**
+	 * Schema identifier this route uses.
+	 *
+	 * @var string
+	 */
+	const SCHEMA_TYPE = 'shopper-list';
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path regex for this REST route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/shopper-lists';
+	}
+
+	/**
+	 * Get method arguments for this REST route.
+	 *
+	 * @return array
+	 */
+	public function get_args() {
+		return array(
+			array(
+				'methods'             => \WP_REST_Server::READABLE,
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => function () {
+					return is_user_logged_in();
+				},
+				'args'                => array(
+					'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+				),
+			),
+			'schema' => array( $this->schema, 'get_public_item_schema' ),
+		);
+	}
+
+	/**
+	 * Return the lists for the current user.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_response( \WP_REST_Request $request ) {
+		$response = array();
+
+		foreach ( ShopperList::get_all_for_user() as $list ) {
+			$response[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $list, $request ) );
+		}
+
+		return rest_ensure_response( $response );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php
new file mode 100644
index 00000000000..6769834e54a
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php
@@ -0,0 +1,92 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+
+/**
+ * GET /shopper-lists/{slug} — metadata for a single list.
+ */
+class ShopperListsBySlug extends AbstractRoute {
+	/**
+	 * Route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'shopper-lists-by-slug';
+
+	/**
+	 * Schema identifier this route uses.
+	 *
+	 * @var string
+	 */
+	const SCHEMA_TYPE = 'shopper-list';
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path regex for this REST route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/shopper-lists/(?P<slug>[a-z0-9-]+)';
+	}
+
+	/**
+	 * Get method arguments for this REST route.
+	 *
+	 * @return array
+	 */
+	public function get_args() {
+		return array(
+			'args'   => array(
+				'slug' => array(
+					'description' => __( 'Stable slug for the list.', 'woocommerce' ),
+					'type'        => 'string',
+				),
+			),
+			array(
+				'methods'             => \WP_REST_Server::READABLE,
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => function () {
+					return is_user_logged_in();
+				},
+				'args'                => array(
+					'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+				),
+			),
+			'schema' => array( $this->schema, 'get_public_item_schema' ),
+		);
+	}
+
+	/**
+	 * Return the list metadata for the requested slug.
+	 *
+	 * @throws RouteException When the list doesn't exist.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_response( \WP_REST_Request $request ) {
+		$list = ShopperList::get_by_slug( (string) $request['slug'] );
+
+		if ( ! $list ) {
+			throw new RouteException( 'woocommerce_rest_shopper_list_not_found', esc_html__( 'Your saved list isn\'t available right now.', 'woocommerce' ), 404 );
+		}
+
+		return rest_ensure_response( $this->prepare_item_for_response( $list, $request ) );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php
new file mode 100644
index 00000000000..d542f30dcf7
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php
@@ -0,0 +1,112 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+
+/**
+ * Stopgap CSRF guard for the write-capable shopper-lists routes.
+ *
+ * Enforces a `wc_store_api` Nonce header on writes and refreshes the
+ * client nonce via response headers on every reply. Same shape as the
+ * cart's existing flow, scoped to the nonce concern.
+ *
+ * To be replaced by a reusable Store API-wide nonce trait once that
+ * lands on trunk.
+ *
+ * @internal
+ */
+trait ShopperListsNonceCheck {
+	/**
+	 * Nonce action used to sign and verify Store API write requests.
+	 *
+	 * @var string
+	 */
+	private static $store_api_nonce_action = 'wc_store_api';
+
+	/**
+	 * Override of {@see AbstractRoute::get_response} that enforces the
+	 * `wc_store_api` Nonce header on writes and refreshes it on every reply.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 * @return \WP_REST_Response
+	 */
+	public function get_response( \WP_REST_Request $request ) {
+		if ( $this->is_write_request( $request ) ) {
+			$nonce_check = $this->check_store_api_nonce( $request );
+			if ( is_wp_error( $nonce_check ) ) {
+				return $this->add_nonce_response_headers( $this->error_to_response( $nonce_check ) );
+			}
+		}
+
+		$response = parent::get_response( $request );
+
+		return $this->add_nonce_response_headers( rest_ensure_response( $response ) );
+	}
+
+	/**
+	 * Whether the request mutates state. Mirrors `AbstractCartRoute::is_update_request`.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 * @return bool
+	 */
+	private function is_write_request( \WP_REST_Request $request ): bool {
+		return in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true );
+	}
+
+	/**
+	 * Verify the `Nonce` request header against the `wc_store_api` action.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 * @return true|\WP_Error True on success, WP_Error on missing/invalid nonce.
+	 */
+	private function check_store_api_nonce( \WP_REST_Request $request ) {
+		/**
+		 * Filters whether to disable the Store API nonce check.
+		 *
+		 * This filter is documented in src/StoreApi/Routes/V1/AbstractCartRoute.php.
+		 *
+		 * @since 4.5.0
+		 *
+		 * @param bool $disable_nonce_check If true, nonce checks will be disabled.
+		 */
+		if ( apply_filters( 'woocommerce_store_api_disable_nonce_check', false ) ) {
+			return true;
+		}
+
+		$nonce = $request->get_header( 'Nonce' );
+		if ( null === $nonce || '' === $nonce ) {
+			return $this->get_route_error_response(
+				'woocommerce_rest_missing_nonce',
+				__( 'Missing the Nonce header. This endpoint requires a valid nonce.', 'woocommerce' ),
+				401
+			);
+		}
+
+		if ( ! wp_verify_nonce( $nonce, self::$store_api_nonce_action ) ) {
+			return $this->get_route_error_response(
+				'woocommerce_rest_invalid_nonce',
+				__( 'Nonce is invalid.', 'woocommerce' ),
+				403
+			);
+		}
+
+		return true;
+	}
+
+	/**
+	 * Attach a fresh `wc_store_api` nonce to the response.
+	 *
+	 * @param \WP_REST_Response $response Response object.
+	 * @return \WP_REST_Response
+	 */
+	private function add_nonce_response_headers( \WP_REST_Response $response ): \WP_REST_Response {
+		$response->header( 'Nonce', wp_create_nonce( self::$store_api_nonce_action ) );
+		$response->header( 'Nonce-Timestamp', (string) time() );
+		$response->header( 'Cache-Control', 'no-store' );
+
+		return $response;
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/RoutesController.php b/plugins/woocommerce/src/StoreApi/RoutesController.php
index 17d457eb39a..6de8e81baed 100644
--- a/plugins/woocommerce/src/StoreApi/RoutesController.php
+++ b/plugins/woocommerce/src/StoreApi/RoutesController.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\StoreApi;

+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
 use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;

@@ -39,7 +40,7 @@ class RoutesController {
 	public function __construct( SchemaController $schema_controller ) {
 		$this->schema_controller = $schema_controller;
 		$this->routes            = [
-			'v1'      => [
+			'v1'            => [
 				Routes\V1\Batch::IDENTIFIER              => Routes\V1\Batch::class,
 				Routes\V1\Cart::IDENTIFIER               => Routes\V1\Cart::class,
 				Routes\V1\CartAddItem::IDENTIFIER        => Routes\V1\CartAddItem::class,
@@ -71,11 +72,18 @@ class RoutesController {
 				Routes\V1\ProductsById::IDENTIFIER       => Routes\V1\ProductsById::class,
 				Routes\V1\ProductsBySlug::IDENTIFIER     => Routes\V1\ProductsBySlug::class,
 			],
-			'private' => [
+			'private'       => [
 				// This route should be moved outside of the Store API namespace.
 				Routes\V1\Patterns::IDENTIFIER => Routes\V1\Patterns::class,
 			],
-			'agentic' => [
+			'shopper_lists' => [
+				// Gated by ShopperListsController — registered only when at least one shopper-list feature is enabled.
+				Routes\V1\ShopperLists::IDENTIFIER       => Routes\V1\ShopperLists::class,
+				Routes\V1\ShopperListsBySlug::IDENTIFIER => Routes\V1\ShopperListsBySlug::class,
+				Routes\V1\ShopperListItems::IDENTIFIER   => Routes\V1\ShopperListItems::class,
+				Routes\V1\ShopperListItemsByKey::IDENTIFIER => Routes\V1\ShopperListItemsByKey::class,
+			],
+			'agentic'       => [
 				// Agentic Commerce Protocol endpoints.
 				Routes\V1\Agentic\CheckoutSessions::IDENTIFIER         => Routes\V1\Agentic\CheckoutSessions::class,
 				Routes\V1\Agentic\CheckoutSessionsUpdate::IDENTIFIER   => Routes\V1\Agentic\CheckoutSessionsUpdate::class,
@@ -92,6 +100,10 @@ class RoutesController {
 		$this->register_routes( 'v1', self::$api_namespace . '/v1' );
 		$this->register_routes( 'private', 'wc/private' );

+		if ( wc_get_container()->get( ShopperListsController::class )->is_enabled() ) {
+			$this->register_routes( 'shopper_lists', self::$api_namespace . '/v1' );
+		}
+
 		if ( FeaturesUtil::feature_is_enabled( 'agentic_checkout' ) ) {
 			$this->register_routes( 'agentic', 'wc/agentic/v1' );
 		}
diff --git a/plugins/woocommerce/src/StoreApi/SchemaController.php b/plugins/woocommerce/src/StoreApi/SchemaController.php
index 7db833a256f..7aa16ca21a3 100644
--- a/plugins/woocommerce/src/StoreApi/SchemaController.php
+++ b/plugins/woocommerce/src/StoreApi/SchemaController.php
@@ -56,6 +56,8 @@ class SchemaController {
 				Schemas\V1\ProductCollectionDataSchema::IDENTIFIER => Schemas\V1\ProductCollectionDataSchema::class,
 				Schemas\V1\ProductReviewSchema::IDENTIFIER => Schemas\V1\ProductReviewSchema::class,
 				Schemas\V1\PatternsSchema::IDENTIFIER      => Schemas\V1\PatternsSchema::class,
+				Schemas\V1\ShopperListSchema::IDENTIFIER   => Schemas\V1\ShopperListSchema::class,
+				Schemas\V1\ShopperListItemSchema::IDENTIFIER => Schemas\V1\ShopperListItemSchema::class,
 				Schemas\V1\Agentic\CheckoutSessionSchema::IDENTIFIER => Schemas\V1\Agentic\CheckoutSessionSchema::class,
 			],
 		];
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
new file mode 100644
index 00000000000..9fdc2a61ba7
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
@@ -0,0 +1,328 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Utilities\ProductItemTrait;
+
+/**
+ * ShopperListItemSchema class.
+ *
+ * Serializes a {@see ShopperListItem}. Renders live product fields when the
+ * item reports `is_live`, and falls back to at-save snapshot data otherwise.
+ */
+class ShopperListItemSchema extends AbstractSchema {
+	// We only call format_variation_data(); see phpstan.neon for the related suppressions.
+	use ProductItemTrait;
+
+	/**
+	 * The schema item name.
+	 *
+	 * @var string
+	 */
+	protected $title = 'shopper_list_item';
+
+	/**
+	 * The schema item identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'shopper-list-item';
+
+	/**
+	 * Image attachment schema instance.
+	 *
+	 * @var ImageAttachmentSchema
+	 */
+	protected $image_attachment_schema;
+
+	/**
+	 * Constructor.
+	 *
+	 * @throws \RuntimeException When the ImageAttachmentSchema is not registered.
+	 *
+	 * @param ExtendSchema     $extend Rest Extending instance.
+	 * @param SchemaController $controller Schema Controller instance.
+	 */
+	public function __construct( ExtendSchema $extend, SchemaController $controller ) {
+		parent::__construct( $extend, $controller );
+		$schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER );
+		if ( ! $schema instanceof ImageAttachmentSchema ) {
+			throw new \RuntimeException( 'ImageAttachmentSchema is not registered in SchemaController.' );
+		}
+		$this->image_attachment_schema = $schema;
+	}
+
+	/**
+	 * Item schema properties.
+	 *
+	 * @return array
+	 */
+	public function get_properties() {
+		return array(
+			'key'            => array(
+				'description' => __( 'Stable identifier for the saved item within its list.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'id'             => array(
+				'description' => __( 'Variation ID if applicable, otherwise product ID.', 'woocommerce' ),
+				'type'        => 'integer',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'product_id'     => array(
+				'description' => __( 'Product ID at the time the item was saved.', 'woocommerce' ),
+				'type'        => 'integer',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'variation_id'   => array(
+				'description' => __( 'Variation ID at the time the item was saved, or 0 for non-variable products.', 'woocommerce' ),
+				'type'        => 'integer',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'quantity'       => array(
+				'description' => __( 'Quantity of this saved item.', 'woocommerce' ),
+				'type'        => 'integer',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'is_live'        => array(
+				'description' => __( 'True when the row serves live product data; false rows are at-save tombstones.', 'woocommerce' ),
+				'type'        => 'boolean',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'is_purchasable' => array(
+				'description' => __( 'True when the product can be added to the cart.', 'woocommerce' ),
+				'type'        => 'boolean',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'name'           => array(
+				'description' => __( 'Product name. Live when is_live is true; falls back to the at-save title snapshot otherwise.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'permalink'      => array(
+				'description' => __( 'Product URL. Null when the row is a tombstone (so iAPI strips the anchor href).', 'woocommerce' ),
+				'type'        => array( 'string', 'null' ),
+				'format'      => 'uri',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'images'         => array(
+				'description' => __( 'List of images for the live product. Empty when the product no longer exists.', 'woocommerce' ),
+				'type'        => 'array',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+				'items'       => array(
+					'type'       => 'object',
+					'properties' => $this->image_attachment_schema->get_properties(),
+				),
+			),
+			'variation'      => array(
+				'description' => __( 'Chosen variation attributes, if applicable.', 'woocommerce' ),
+				'type'        => 'array',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+				'items'       => array(
+					'type'       => 'object',
+					'properties' => array(
+						'raw_attribute' => array(
+							'description' => __( 'Variation system generated attribute name.', 'woocommerce' ),
+							'type'        => 'string',
+							'context'     => array( 'view', 'edit' ),
+							'readonly'    => true,
+						),
+						'attribute'     => array(
+							'description' => __( 'Variation attribute name.', 'woocommerce' ),
+							'type'        => 'string',
+							'context'     => array( 'view', 'edit' ),
+							'readonly'    => true,
+						),
+						'value'         => array(
+							'description' => __( 'Variation attribute value.', 'woocommerce' ),
+							'type'        => 'string',
+							'context'     => array( 'view', 'edit' ),
+							'readonly'    => true,
+						),
+					),
+				),
+			),
+			'prices'         => array(
+				'description' => __( 'Live product prices. Omitted when the product no longer exists.', 'woocommerce' ),
+				'type'        => array( 'object', 'null' ),
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+				'properties'  => array_merge(
+					$this->get_store_currency_properties(),
+					array(
+						'price'         => array(
+							'description' => __( 'Current product price.', 'woocommerce' ),
+							'type'        => 'string',
+							'context'     => array( 'view', 'edit' ),
+							'readonly'    => true,
+						),
+						'regular_price' => array(
+							'description' => __( 'Regular product price.', 'woocommerce' ),
+							'type'        => 'string',
+							'context'     => array( 'view', 'edit' ),
+							'readonly'    => true,
+						),
+						'sale_price'    => array(
+							'description' => __( 'Sale product price, if applicable.', 'woocommerce' ),
+							'type'        => 'string',
+							'context'     => array( 'view', 'edit' ),
+							'readonly'    => true,
+						),
+					)
+				),
+			),
+			'price_html'     => array(
+				'description' => __( 'Live product price as HTML, formatted via wc_price including sale/discount markup. Empty when the product no longer exists.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'image_html'     => array(
+				'description' => __( 'Product thumbnail as a fully-formed <img> element with srcset, sizes, alt, and lazy-loading attributes. Falls back to the configured placeholder image when the product has no image or no longer exists.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'date_added_gmt' => array(
+				'description' => __( 'The date the item was saved, as GMT.', 'woocommerce' ),
+				'type'        => 'string',
+				'format'      => 'date-time',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+		);
+	}
+
+	/**
+	 * Serialize the saved item.
+	 *
+	 * @param ShopperListItem $item Saved item.
+	 * @return array
+	 */
+	public function get_item_response( $item ) {
+		$variation_id = $item->get_variation_id();
+		$product_id   = $variation_id > 0 ? $variation_id : $item->get_product_id();
+		$product      = $item->get_product();
+		$is_live      = $item->is_live();
+
+		$response = array(
+			'key'            => $item->get_key(),
+			'id'             => $product_id,
+			'product_id'     => $item->get_product_id(),
+			'variation_id'   => $variation_id,
+			'quantity'       => $item->get_quantity(),
+			'is_live'        => $is_live,
+			'is_purchasable' => $item->is_purchasable(),
+			'date_added_gmt' => wc_rest_prepare_date_response( $item->get_date_added_gmt() ),
+		);
+
+		if ( $is_live && $product instanceof \WC_Product ) {
+			$response['name']       = $this->get_name( $product );
+			$response['permalink']  = $product->get_permalink();
+			$response['images']     = $this->get_images( $product );
+			$response['variation']  = $this->format_variation_data( $item->get_variation_attributes(), $product );
+			$response['prices']     = (object) $this->get_prices( $product );
+			$response['price_html'] = (string) $product->get_price_html();
+			$response['image_html'] = $this->get_image_html( $product );
+		} else {
+			$response['name']       = $this->prepare_html_response( $item->get_product_title_at_save() );
+			$response['permalink']  = null;
+			$response['images']     = array();
+			$response['variation']  = array();
+			$response['prices']     = null;
+			$response['price_html'] = '';
+			$response['image_html'] = $this->get_image_html( null );
+		}
+
+		return $response;
+	}
+
+	/**
+	 * Get the displayable name for the live product.
+	 *
+	 * @param \WC_Product $product Live product instance.
+	 * @return string
+	 */
+	private function get_name( \WC_Product $product ): string {
+		$prepared = $this->prepare_html_response( $product->get_title() );
+		return is_string( $prepared ) ? $prepared : (string) $product->get_title();
+	}
+
+	/**
+	 * Get the main image for a shopper list item.
+	 *
+	 * Returns the product's main image only — shopper list rows are compact and
+	 * the gallery isn't needed at the row level.
+	 *
+	 * @param \WC_Product $product Live product instance.
+	 * @return array
+	 */
+	private function get_images( \WC_Product $product ): array {
+		$image_id = (int) $product->get_image_id();
+		if ( $image_id <= 0 ) {
+			return array();
+		}
+
+		$image = $this->image_attachment_schema->get_item_response( $image_id );
+		return $image ? array( $image ) : array();
+	}
+
+	/**
+	 * Get the thumbnail image HTML for a shopper list item, falling back to the
+	 * WooCommerce placeholder when the product has no image or has been deleted.
+	 *
+	 * Pre-formatting on the server lets renderers (PHP SSR + JS hydration)
+	 * consume one canonical string instead of each side composing the markup
+	 * from the structured `images` array. Mirrors the pattern WC uses in
+	 * `ProductSchema::price_html` / `ProductImage::render`.
+	 *
+	 * @param \WC_Product|null $product Live product instance, or null for tombstones.
+	 * @return string
+	 */
+	private function get_image_html( ?\WC_Product $product ): string {
+		$image_id = $product instanceof \WC_Product ? (int) $product->get_image_id() : 0;
+		if ( $image_id > 0 ) {
+			return (string) wp_get_attachment_image( $image_id, 'woocommerce_thumbnail' );
+		}
+		return (string) wc_placeholder_img( 'woocommerce_thumbnail' );
+	}
+
+	/**
+	 * Compute live prices for the saved item.
+	 *
+	 * We don't extend ProductSchema because saved items aren't products. The shape
+	 * here is a thin subset of cart-item prices.
+	 *
+	 * @param \WC_Product $product Live product instance.
+	 * @return array
+	 */
+	private function get_prices( \WC_Product $product ): array {
+		$decimals      = wc_get_price_decimals();
+		$regular_price = $product->get_regular_price();
+		$sale_price    = $product->get_sale_price();
+		$current_price = $product->get_price();
+
+		return $this->prepare_currency_response(
+			array(
+				'price'         => $this->prepare_money_response( $current_price, $decimals ),
+				'regular_price' => $this->prepare_money_response( '' === $regular_price ? $current_price : $regular_price, $decimals ),
+				'sale_price'    => '' === $sale_price ? '' : $this->prepare_money_response( $sale_price, $decimals ),
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListSchema.php
new file mode 100644
index 00000000000..60352ce2982
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListSchema.php
@@ -0,0 +1,128 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+
+/**
+ * ShopperListSchema class.
+ *
+ * Represents a single shopper list, including its saved items.
+ */
+class ShopperListSchema extends AbstractSchema {
+	/**
+	 * The schema item name.
+	 *
+	 * @var string
+	 */
+	protected $title = 'shopper_list';
+
+	/**
+	 * The schema item identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'shopper-list';
+
+	/**
+	 * Item schema instance.
+	 *
+	 * @var ShopperListItemSchema
+	 */
+	protected $item_schema;
+
+	/**
+	 * Constructor.
+	 *
+	 * @throws \RuntimeException When the ShopperListItemSchema is not registered.
+	 *
+	 * @param ExtendSchema     $extend Rest Extending instance.
+	 * @param SchemaController $controller Schema Controller instance.
+	 */
+	public function __construct( ExtendSchema $extend, SchemaController $controller ) {
+		parent::__construct( $extend, $controller );
+		$schema = $this->controller->get( ShopperListItemSchema::IDENTIFIER );
+		if ( ! $schema instanceof ShopperListItemSchema ) {
+			throw new \RuntimeException( 'ShopperListItemSchema is not registered in SchemaController.' );
+		}
+		$this->item_schema = $schema;
+	}
+
+	/**
+	 * Schema properties.
+	 *
+	 * @return array
+	 */
+	public function get_properties() {
+		return array(
+			'slug'             => array(
+				'description' => __( 'Stable slug for the list.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'date_created_gmt' => array(
+				'description' => __( 'The date the list was created, as GMT.', 'woocommerce' ),
+				'type'        => 'string',
+				'format'      => 'date-time',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'item_count'       => array(
+				'description' => __( 'Number of items currently in the list.', 'woocommerce' ),
+				'type'        => 'integer',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+			),
+			'items'            => array(
+				'description' => __( 'List of saved items.', 'woocommerce' ),
+				'type'        => 'array',
+				'context'     => array( 'view', 'edit' ),
+				'readonly'    => true,
+				'items'       => array(
+					'type'       => 'object',
+					'properties' => $this->force_schema_readonly( $this->item_schema->get_properties() ),
+				),
+			),
+		);
+	}
+
+	/**
+	 * Serialize the shopper list.
+	 *
+	 * @param ShopperList $shopper_list The list.
+	 * @return array
+	 */
+	public function get_item_response( $shopper_list ) {
+		$items = array_values( $shopper_list->get_items() );
+
+		$product_ids = array_filter(
+			array_map(
+				static function ( ShopperListItem $item ): int {
+					$variation_id = $item->get_variation_id();
+					return $variation_id > 0 ? $variation_id : $item->get_product_id();
+				},
+				$items
+			)
+		);
+		if ( ! empty( $product_ids ) ) {
+			_prime_post_caches( array_unique( $product_ids ) );
+		}
+
+		return array(
+			'slug'             => $shopper_list->get_slug(),
+			'date_created_gmt' => wc_rest_prepare_date_response( $shopper_list->get_date_created_gmt() ),
+			'item_count'       => count( $items ),
+			'items'            => array_values(
+				array_map(
+					fn( ShopperListItem $item ) => $this->item_schema->get_item_response( $item ),
+					$items
+				)
+			),
+		);
+	}
+}
diff --git a/plugins/woocommerce/templates/parts/external-product-add-to-cart-with-options.html b/plugins/woocommerce/templates/parts/external-product-add-to-cart-with-options.html
index 705b43a5cd8..fb3866329db 100644
--- a/plugins/woocommerce/templates/parts/external-product-add-to-cart-with-options.html
+++ b/plugins/woocommerce/templates/parts/external-product-add-to-cart-with-options.html
@@ -1,3 +1,4 @@
 <!-- wp:group {"layout":{"type":"flex","flexWrap":"nowrap"}} -->
 <div class="wp-block-group"><!-- wp:woocommerce/product-button {"textAlign":"left"} /--></div>
 <!-- /wp:group -->
+<!-- wp:woocommerce/add-to-wishlist-button /-->
diff --git a/plugins/woocommerce/templates/parts/grouped-product-add-to-cart-with-options.html b/plugins/woocommerce/templates/parts/grouped-product-add-to-cart-with-options.html
index 5e296b3d1e6..60b5b28b271 100644
--- a/plugins/woocommerce/templates/parts/grouped-product-add-to-cart-with-options.html
+++ b/plugins/woocommerce/templates/parts/grouped-product-add-to-cart-with-options.html
@@ -31,3 +31,4 @@
 <!-- wp:group {"layout":{"type":"flex","flexWrap":"nowrap"}} -->
 <div class="wp-block-group"><!-- wp:woocommerce/product-button {"textAlign":"left"} /--></div>
 <!-- /wp:group -->
+<!-- wp:woocommerce/add-to-wishlist-button /-->
diff --git a/plugins/woocommerce/templates/parts/simple-product-add-to-cart-with-options.html b/plugins/woocommerce/templates/parts/simple-product-add-to-cart-with-options.html
index 0a6d6b24f33..9eafe827d77 100644
--- a/plugins/woocommerce/templates/parts/simple-product-add-to-cart-with-options.html
+++ b/plugins/woocommerce/templates/parts/simple-product-add-to-cart-with-options.html
@@ -3,3 +3,4 @@
 <div class="wp-block-group"><!-- wp:woocommerce/add-to-cart-with-options-quantity-selector {"quantitySelectorStyle":"stepper"} /-->
 <!-- wp:woocommerce/product-button {"textAlign":"left"} /--></div>
 <!-- /wp:group -->
+<!-- wp:woocommerce/add-to-wishlist-button /-->
diff --git a/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html b/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html
index f9eb81d8eb0..f6f09c10bff 100644
--- a/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html
+++ b/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html
@@ -36,3 +36,5 @@
 	<!-- wp:woocommerce/product-button {"textAlign":"left"} /-->
 </div>
 <!-- /wp:group -->
+
+<!-- wp:woocommerce/add-to-wishlist-button /-->
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
index 5b03b6c1e85..2663359c8f4 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Assets/AssetDataRegistry.php
@@ -3,6 +3,7 @@
 namespace Automattic\WooCommerce\Tests\Blocks\Assets;

 use Automattic\WooCommerce\Blocks\Assets\Api;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
 use Automattic\WooCommerce\Blocks\Package;
 use InvalidArgumentException;
@@ -86,4 +87,42 @@ class AssetDataRegistry extends \WP_UnitTestCase {
 		$this->assertEquals( $original_data, $data );
 		remove_filter( 'woocommerce_shared_settings', [ self::class, 'ndcallback' ] );
 	}
+
+	/**
+	 * @testdox `experimentalCartSaveForLater` is registered as true when the `cart_save_for_later` feature is enabled.
+	 */
+	public function test_experimental_cart_save_for_later_setting_is_true_when_feature_enabled() {
+		$features_controller = wc_get_container()->get( FeaturesController::class );
+		$original_enabled    = $features_controller->feature_is_enabled( 'cart_save_for_later' );
+
+		$features_controller->change_feature_enable( 'cart_save_for_later', true );
+		try {
+			$this->registry->initialize_core_data();
+			$data = $this->registry->get();
+
+			$this->assertArrayHasKey( 'experimentalCartSaveForLater', $data );
+			$this->assertTrue( $data['experimentalCartSaveForLater'] );
+		} finally {
+			$features_controller->change_feature_enable( 'cart_save_for_later', $original_enabled );
+		}
+	}
+
+	/**
+	 * @testdox `experimentalCartSaveForLater` is registered as false when the `cart_save_for_later` feature is disabled.
+	 */
+	public function test_experimental_cart_save_for_later_setting_is_false_when_feature_disabled() {
+		$features_controller = wc_get_container()->get( FeaturesController::class );
+		$original_enabled    = $features_controller->feature_is_enabled( 'cart_save_for_later' );
+
+		$features_controller->change_feature_enable( 'cart_save_for_later', false );
+		try {
+			$this->registry->initialize_core_data();
+			$data = $this->registry->get();
+
+			$this->assertArrayHasKey( 'experimentalCartSaveForLater', $data );
+			$this->assertFalse( $data['experimentalCartSaveForLater'] );
+		} finally {
+			$features_controller->change_feature_enable( 'cart_save_for_later', $original_enabled );
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToWishlistButtonTests.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToWishlistButtonTests.php
new file mode 100644
index 00000000000..edb2ee3ca23
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToWishlistButtonTests.php
@@ -0,0 +1,296 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Assets\Api;
+use Automattic\WooCommerce\Blocks\BlockTypes\AddToWishlistButton;
+use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionProperty;
+use stdClass;
+use WP_Block_Supports;
+use WP_UnitTestCase;
+
+/**
+ * Tests for the AddToWishlistButton block type.
+ */
+class AddToWishlistButtonTests extends WP_UnitTestCase {
+
+	/**
+	 * System under test, instantiated without invoking the constructor so the
+	 * test bootstrap's existing block registration isn't re-applied. The
+	 * methods under test only read `$this->namespace` / `$this->block_name`
+	 * (class defaults) plus an injected `asset_data_registry`.
+	 *
+	 * @var AddToWishlistButton
+	 */
+	private AddToWishlistButton $sut;
+
+	/**
+	 * Cached for restoration in tearDown — render() temporarily sets it so
+	 * `get_block_wrapper_attributes()` can resolve layout/style supports.
+	 *
+	 * @var array|null
+	 */
+	private ?array $previous_block_to_render = null;
+
+	/**
+	 * Instantiate the block without invoking its constructor and inject the
+	 * registry mock so render() can call its registered helpers without
+	 * NPEing.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$reflection = new ReflectionClass( AddToWishlistButton::class );
+		$this->sut  = $reflection->newInstanceWithoutConstructor();
+
+		$registry_prop = new ReflectionProperty( AddToWishlistButton::class, 'asset_data_registry' );
+		$registry_prop->setAccessible( true );
+		$registry_prop->setValue(
+			$this->sut,
+			new AssetDataRegistryMock( Package::container()->get( Api::class ) )
+		);
+	}
+
+	/**
+	 * Reset any global state we mutated for `get_block_wrapper_attributes()`.
+	 */
+	public function tearDown(): void {
+		if ( null !== $this->previous_block_to_render ) {
+			WP_Block_Supports::$block_to_render = $this->previous_block_to_render;
+			$this->previous_block_to_render     = null;
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * Build a minimal $block stub carrying a `context` array — enough for
+	 * `render()` to read `$block->context['postId']`. A `stdClass` is
+	 * sufficient because the renderer only ever reads via property access.
+	 *
+	 * @param int|null $post_id Post ID to seed (or null for the "no context" case).
+	 * @return stdClass
+	 */
+	private function build_block_stub( ?int $post_id ): stdClass {
+		$block          = new stdClass();
+		$block->context = null === $post_id ? array() : array( 'postId' => $post_id );
+		return $block;
+	}
+
+	/**
+	 * Call the protected `render()` method via reflection. Sets
+	 * `WP_Block_Supports::$block_to_render` so `get_block_wrapper_attributes()`
+	 * inside render() has the context it expects when invoked outside the
+	 * normal block-render pipeline.
+	 *
+	 * @param stdClass|null $block Block stub.
+	 * @return string Rendered markup.
+	 */
+	private function invoke_render( ?stdClass $block ): string {
+		$attributes = array();
+
+		$this->previous_block_to_render     = WP_Block_Supports::$block_to_render;
+		WP_Block_Supports::$block_to_render = array(
+			'blockName' => 'woocommerce/add-to-wishlist-button',
+			'attrs'     => $attributes,
+		);
+
+		$render = new ReflectionMethod( AddToWishlistButton::class, 'render' );
+		$render->setAccessible( true );
+
+		return (string) $render->invoke( $this->sut, $attributes, '', $block );
+	}
+
+	/**
+	 * `render()` returns an empty string for logged-out shoppers, before any
+	 * product/context lookups happen.
+	 */
+	public function test_render_returns_empty_for_logged_out_user(): void {
+		wp_set_current_user( 0 );
+
+		$this->assertSame(
+			'',
+			$this->invoke_render( $this->build_block_stub( 12345 ) ),
+			'Guests must never see the wishlist trigger.'
+		);
+	}
+
+	/**
+	 * `render()` returns an empty string when `$block->context` has no
+	 * `postId` — the button has no product to act on.
+	 */
+	public function test_render_returns_empty_when_no_post_id_in_context(): void {
+		$customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $customer_id );
+
+		$this->assertSame( '', $this->invoke_render( $this->build_block_stub( null ) ) );
+	}
+
+	/**
+	 * `render()` returns an empty string when the supplied `postId` doesn't
+	 * resolve to a `WC_Product` (e.g. the post was deleted, or it's a non-
+	 * product post type).
+	 */
+	public function test_render_returns_empty_when_product_cannot_be_loaded(): void {
+		$customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $customer_id );
+
+		// 999999 is intentionally a non-existent post ID.
+		$this->assertSame( '', $this->invoke_render( $this->build_block_stub( 999999 ) ) );
+	}
+
+	/**
+	 * For a logged-in shopper on a simple product not in the wishlist, the
+	 * rendered markup carries the iAPI wrapper, the per-block context (with
+	 * `isVariableType` false and `isPending` false), and the empty-star
+	 * initial state with `aria-pressed="false"`.
+	 */
+	public function test_render_simple_product_not_in_wishlist(): void {
+		$customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $customer_id );
+
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		$markup = $this->invoke_render( $this->build_block_stub( $product_id ) );
+
+		$this->assertStringContainsString(
+			'data-wp-interactive="woocommerce/add-to-wishlist-button"',
+			$markup,
+			'Wrapper must declare its iAPI interactive scope.'
+		);
+
+		// `data-wp-context` is HTML-encoded; the embedded quotes appear as `&quot;`.
+		$this->assertStringContainsString( '&quot;productId&quot;:' . $product_id, $markup );
+		$this->assertStringContainsString( '&quot;isVariableType&quot;:false', $markup );
+		$this->assertStringContainsString( '&quot;isPending&quot;:false', $markup );
+
+		$this->assertStringContainsString(
+			'aria-pressed="false"',
+			$markup,
+			'Simple product not in the wishlist starts with aria-pressed=false.'
+		);
+		$this->assertStringNotContainsString(
+			' disabled',
+			$markup,
+			'Simple products are always actionable — never disabled on first paint.'
+		);
+	}
+
+	/**
+	 * For a variable product, the button must be `disabled` on first paint
+	 * and show the "Select options first" label, since no variation has been
+	 * selected yet.
+	 */
+	public function test_render_variable_product_is_disabled_with_select_options_label(): void {
+		$customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $customer_id );
+
+		$product    = \WC_Helper_Product::create_variation_product();
+		$product_id = $product->get_id();
+
+		$markup = $this->invoke_render( $this->build_block_stub( $product_id ) );
+
+		$this->assertStringContainsString( '&quot;isVariableType&quot;:true', $markup );
+
+		$this->assertMatchesRegularExpression(
+			'/<button[^>]*\bdisabled\b/',
+			$markup,
+			'Variable product without a selected variation must render the button disabled.'
+		);
+
+		$this->assertStringContainsString(
+			'Select options first',
+			$markup,
+			'Initial label for variable products is "Select options first".'
+		);
+	}
+
+	/**
+	 * The hidden empty-star span and the (initially hidden) filled-star span
+	 * are both present, so iAPI can toggle visibility without DOM swaps.
+	 */
+	public function test_render_emits_both_star_icons(): void {
+		$customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $customer_id );
+
+		$product    = \WC_Helper_Product::create_simple_product();
+		$product_id = $product->get_id();
+
+		$markup = $this->invoke_render( $this->build_block_stub( $product_id ) );
+
+		$this->assertStringContainsString( 'wc-block-add-to-wishlist-button__icon--empty', $markup );
+		$this->assertStringContainsString( 'wc-block-add-to-wishlist-button__icon--filled', $markup );
+
+		// Both spans are bound to `state.isInWishlist` (one positive, one
+		// negated) so iAPI can toggle without removing/re-adding nodes.
+		$this->assertStringContainsString( 'data-wp-bind--hidden="state.isInWishlist"', $markup );
+		$this->assertStringContainsString( 'data-wp-bind--hidden="!state.isInWishlist"', $markup );
+	}
+
+	/**
+	 * `is_initial_in_wishlist()` is the small helper that decides whether the
+	 * SSR-rendered button starts in the "saved" state. Unit-testable in
+	 * isolation: pass a synthetic prefetched-items array plus a product and
+	 * assert the boolean result without touching the REST layer.
+	 *
+	 * @return array<string, array{array<int, array<string, mixed>>, string, bool}>
+	 */
+	public function provider_is_initial_in_wishlist(): array {
+		return array(
+			'simple in list'                  => array(
+				array( array( 'id' => 42 ) ),
+				'simple',
+				true,
+			),
+			'simple not in list'              => array(
+				array( array( 'id' => 99 ) ),
+				'simple',
+				false,
+			),
+			'simple with empty list'          => array(
+				array(),
+				'simple',
+				false,
+			),
+			'variable parent ignores entries' => array(
+				array( array( 'id' => 42 ) ),
+				'variable',
+				false,
+			),
+		);
+	}
+
+	/**
+	 * @dataProvider provider_is_initial_in_wishlist
+	 *
+	 * @param array<int, array<string, mixed>> $items    Schema-shape items from the prefetch.
+	 * @param string                           $type     Product type to construct ("simple" or "variable").
+	 * @param bool                             $expected Expected boolean result.
+	 */
+	public function test_is_initial_in_wishlist( array $items, string $type, bool $expected ): void {
+		$product = 'variable' === $type
+			? \WC_Helper_Product::create_variation_product()
+			: \WC_Helper_Product::create_simple_product();
+
+		// Re-key the synthetic items to use this product's real ID for the
+		// "in list" cases, so the test data stays independent of the
+		// auto-generated post IDs the factory hands out.
+		$normalized = array();
+		foreach ( $items as $item ) {
+			if ( isset( $item['id'] ) && 42 === $item['id'] ) {
+				$item['id'] = $product->get_id();
+			}
+			$normalized[] = $item;
+		}
+
+		$method = new ReflectionMethod( AddToWishlistButton::class, 'is_initial_in_wishlist' );
+		$method->setAccessible( true );
+
+		$this->assertSame( $expected, $method->invoke( $this->sut, $normalized, $product ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php
new file mode 100644
index 00000000000..8465c94e246
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php
@@ -0,0 +1,404 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Blocks\Assets\Api;
+use Automattic\WooCommerce\Blocks\BlockTypes\SavedForLater;
+use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionProperty;
+use WP_UnitTestCase;
+
+/**
+ * Tests for the SavedForLater block type.
+ */
+class SavedForLaterTests extends WP_UnitTestCase {
+
+	/**
+	 * System under test.
+	 *
+	 * Constructed via reflection so `AbstractBlock::__construct` doesn't run
+	 * `parent::initialize()` and re-register the block (the test bootstrap
+	 * has already registered it). The filter callbacks under test only read
+	 * `$this->namespace` and `$this->block_name`, both class defaults.
+	 *
+	 * @var SavedForLater
+	 */
+	private SavedForLater $sut;
+
+	/**
+	 * Instantiate the block without invoking its constructor and inject a
+	 * registry mock so render() can call `->add()` without NPEing.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$reflection = new ReflectionClass( SavedForLater::class );
+		$this->sut  = $reflection->newInstanceWithoutConstructor();
+
+		$registry_prop = new ReflectionProperty( SavedForLater::class, 'asset_data_registry' );
+		$registry_prop->setAccessible( true );
+		$registry_prop->setValue(
+			$this->sut,
+			new AssetDataRegistryMock( Package::container()->get( Api::class ) )
+		);
+	}
+
+	/**
+	 * @return array<string, array{string, string, bool, bool}>
+	 */
+	public function provider_register_hooked_block(): array {
+		$cart_only       = '<!-- wp:woocommerce/cart /-->';
+		$cart_with_block = '<!-- wp:woocommerce/cart /--><!-- wp:woocommerce/saved-for-later /-->';
+
+		return array(
+			// label                                => array( cart_page_content, anchor, context_is_cart_page, expected_hooked ).
+			'hooked after cart on cart page'        => array( $cart_only, 'woocommerce/cart', true, true ),
+			'not hooked after non-cart anchor'      => array( $cart_only, 'core/paragraph', true, false ),
+			'not hooked when context is other page' => array( $cart_only, 'woocommerce/cart', false, false ),
+			'not hooked when already present'       => array( $cart_with_block, 'woocommerce/cart', true, false ),
+		);
+	}
+
+	/**
+	 * `register_hooked_block` only adds the block when the anchor is `woocommerce/cart`,
+	 * the context is the cart page, and the cart page doesn't already contain the block.
+	 *
+	 * @dataProvider provider_register_hooked_block
+	 *
+	 * @param string $cart_page_content    Initial content of the cart page.
+	 * @param string $anchor               Anchor block name passed to the filter.
+	 * @param bool   $context_is_cart_page Whether the filter context is the cart page or some other page.
+	 * @param bool   $expected_hooked      Whether the block should end up in the hooked list.
+	 */
+	public function test_register_hooked_block( string $cart_page_content, string $anchor, bool $context_is_cart_page, bool $expected_hooked ): void {
+		$cart_page_id = self::factory()->post->create(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_content' => $cart_page_content,
+			)
+		);
+		update_option( 'woocommerce_cart_page_id', $cart_page_id );
+
+		$context_id = $context_is_cart_page
+			? $cart_page_id
+			: self::factory()->post->create(
+				array(
+					'post_type'   => 'page',
+					'post_status' => 'publish',
+				)
+			);
+
+		$hooked = $this->sut->register_hooked_block( array(), 'after', $anchor, get_post( $context_id ) );
+
+		if ( $expected_hooked ) {
+			$this->assertContains( 'woocommerce/saved-for-later', $hooked );
+		} else {
+			$this->assertNotContains( 'woocommerce/saved-for-later', $hooked );
+		}
+	}
+
+	/**
+	 * When the cart page option is unset, `wc_get_page_id()` returns -1 — the filter
+	 * must treat that as "no cart page" rather than letting it match a real post ID.
+	 */
+	public function test_register_hooked_block_skips_when_cart_page_unset(): void {
+		delete_option( 'woocommerce_cart_page_id' );
+
+		$context_id = self::factory()->post->create(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_content' => '<!-- wp:woocommerce/cart /-->',
+			)
+		);
+
+		$hooked = $this->sut->register_hooked_block( array(), 'after', 'woocommerce/cart', get_post( $context_id ) );
+
+		$this->assertNotContains( 'woocommerce/saved-for-later', $hooked );
+	}
+
+	/**
+	 * The auto-injected block ships with a seeded `core/heading` inner block so
+	 * fresh cart pages render the heading on the frontend out of the box. The
+	 * matching `null` push onto `innerContent` is what makes `WP_Block::render()`
+	 * walk into the heading when building `$content`.
+	 */
+	public function test_hooked_block_attributes_seed_heading_inner_block(): void {
+		$parsed_hooked_block = array(
+			'blockName' => 'woocommerce/saved-for-later',
+			'attrs'     => array(),
+		);
+		$parsed_anchor_block = array( 'blockName' => 'woocommerce/cart' );
+
+		$result = $this->sut->set_hooked_block_attributes(
+			$parsed_hooked_block,
+			'woocommerce/saved-for-later',
+			'after',
+			$parsed_anchor_block
+		);
+
+		$this->assertArrayHasKey( 'innerBlocks', $result );
+		$this->assertCount( 1, $result['innerBlocks'] );
+
+		$heading = $result['innerBlocks'][0];
+		$this->assertSame( 'core/heading', $heading['blockName'] );
+		$this->assertSame( 2, $heading['attrs']['level'] );
+		$this->assertArrayHasKey( 'content', $heading['attrs'] );
+		// `attrs.content` is the raw translated string (no `esc_html`) —
+		// JSON encoding handles escaping at serialization time. Asserts
+		// the en_US source string the test bootstrap runs under.
+		$this->assertSame( 'Saved for later', $heading['attrs']['content'] );
+		$this->assertStringContainsString( '<h2 class="wp-block-heading">', $heading['innerHTML'] );
+		$this->assertSame( array( $heading['innerHTML'] ), $heading['innerContent'] );
+
+		$this->assertArrayHasKey( 'innerContent', $result );
+		$this->assertContains( null, $result['innerContent'] );
+	}
+
+	/**
+	 * Extensions are free to hook `hooked_block_woocommerce/saved-for-later`
+	 * to add their own inner blocks at a different priority. Our heading must
+	 * still be seeded alongside, not in place of, what they added.
+	 */
+	public function test_hooked_block_attributes_appends_heading_alongside_existing_inner_blocks(): void {
+		$existing_block      = array(
+			'blockName'    => 'core/paragraph',
+			'attrs'        => array(),
+			'innerBlocks'  => array(),
+			'innerHTML'    => '<p>From another extension</p>',
+			'innerContent' => array( '<p>From another extension</p>' ),
+		);
+		$parsed_hooked_block = array(
+			'blockName'    => 'woocommerce/saved-for-later',
+			'attrs'        => array(),
+			'innerBlocks'  => array( $existing_block ),
+			'innerContent' => array( null ),
+		);
+		$parsed_anchor_block = array( 'blockName' => 'woocommerce/cart' );
+
+		$result = $this->sut->set_hooked_block_attributes(
+			$parsed_hooked_block,
+			'woocommerce/saved-for-later',
+			'after',
+			$parsed_anchor_block
+		);
+
+		$this->assertCount( 2, $result['innerBlocks'] );
+		// The other-extension block is preserved at its original index.
+		$this->assertSame( $existing_block, $result['innerBlocks'][0] );
+		// Our heading is appended after it.
+		$this->assertSame( 'core/heading', $result['innerBlocks'][1]['blockName'] );
+		$this->assertSame( 'Saved for later', $result['innerBlocks'][1]['attrs']['content'] );
+		// Parent innerContent gains a `null` placeholder for each inner block,
+		// so `WP_Block::render()` walks into both when building `$content`.
+		$this->assertCount( 2, $result['innerContent'] );
+	}
+
+	/**
+	 * `render()` returns an empty string for logged-out shoppers.
+	 */
+	public function test_render_returns_empty_for_logged_out_user(): void {
+		wp_set_current_user( 0 );
+
+		$render = new ReflectionMethod( SavedForLater::class, 'render' );
+		$render->setAccessible( true );
+
+		$this->assertSame( '', (string) $render->invoke( $this->sut, array(), '', null ) );
+	}
+
+	/**
+	 * For a logged-in shopper whose list is empty (the new-shopper /
+	 * never-saved-an-item case), SSR must:
+	 *   - emit the empty-state `<li>` already `hidden`, so the message
+	 *     never flashes between paint and iAPI hydration, and
+	 *   - seed the wrapper's iAPI context with `hasShownItems: false` and
+	 *     the matching `data-wp-watch` callback, so the JS-side
+	 *     `state.isEmpty` getter has the inputs it needs to keep the
+	 *     message hidden until the shopper has actually saved an item.
+	 *
+	 * With no saved items, `prefetch_items()` returns `[]` whether
+	 * the Store API route is registered or not (a 404 still resolves to
+	 * an empty array), so this stays a unit-level assertion without
+	 * feature-flag wiring or fixture items. Sets
+	 * `WP_Block_Supports::$block_to_render` up front so
+	 * `get_block_wrapper_attributes()` (which reads it for layout/style
+	 * supports) has the context it expects when called outside the
+	 * usual block-render pipeline.
+	 */
+	public function test_render_seeds_hidden_empty_state_for_new_shopper(): void {
+		$customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $customer_id );
+
+		$attributes = array();
+
+		$previous_block_to_render            = \WP_Block_Supports::$block_to_render;
+		\WP_Block_Supports::$block_to_render = array(
+			'blockName' => 'woocommerce/saved-for-later',
+			'attrs'     => $attributes,
+		);
+
+		try {
+			$render = new ReflectionMethod( SavedForLater::class, 'render' );
+			$render->setAccessible( true );
+
+			$markup = (string) $render->invoke( $this->sut, $attributes, '', null );
+		} finally {
+			\WP_Block_Supports::$block_to_render = $previous_block_to_render;
+		}
+
+		// The empty-state `<li>` is always rendered, always initially hidden.
+		$this->assertMatchesRegularExpression(
+			'/<li[^>]*class="wc-block-saved-for-later__empty"[^>]*\bhidden\b/',
+			$markup,
+			'Empty-state <li> must be initially hidden so the message does not flash before iAPI hydration.'
+		);
+
+		// The wrapper's `data-wp-context` JSON is HTML-escaped into an
+		// attribute, so the embedded quotes appear as `&quot;` in the
+		// rendered markup.
+		$this->assertStringContainsString(
+			'&quot;hasShownItems&quot;:false',
+			$markup,
+			'Wrapper context must seed hasShownItems=false for an empty list so the empty message stays hidden until the shopper actually saves an item.'
+		);
+
+		$this->assertStringContainsString(
+			'data-wp-watch="callbacks.trackShownItems"',
+			$markup,
+			'Wrapper must wire the trackShownItems watcher so hasShownItems can flip to true the first time items appear in-session.'
+		);
+	}
+
+	/**
+	 * The seeded heading (and any future sibling inner blocks rendered via
+	 * `$content`) must share the empty-state visibility gating: hidden on
+	 * first paint for new shoppers / empty refreshes, revealed once the
+	 * iAPI watcher flips `context.hasShownItems`. Without this, a saved
+	 * cart page rendered with no items would show an orphaned heading
+	 * sitting above nothing.
+	 */
+	public function test_render_wraps_header_with_hidden_visibility_gate_when_empty(): void {
+		$customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $customer_id );
+
+		$attributes = array();
+
+		$previous_block_to_render            = \WP_Block_Supports::$block_to_render;
+		\WP_Block_Supports::$block_to_render = array(
+			'blockName' => 'woocommerce/saved-for-later',
+			'attrs'     => $attributes,
+		);
+
+		try {
+			$render = new ReflectionMethod( SavedForLater::class, 'render' );
+			$render->setAccessible( true );
+
+			$content = '<h2 class="wp-block-heading">Saved for later</h2>';
+			$markup  = (string) $render->invoke( $this->sut, $attributes, $content, null );
+		} finally {
+			\WP_Block_Supports::$block_to_render = $previous_block_to_render;
+		}
+
+		// The header wrapper exists, contains the heading, has the iAPI
+		// visibility bind, and is initially hidden because items is empty.
+		$this->assertMatchesRegularExpression(
+			'/<div[^>]*class="wc-block-saved-for-later__header"[^>]*data-wp-bind--hidden="!context\.hasShownItems"[^>]*\bhidden\b[^>]*>.*Saved for later/s',
+			$markup,
+			'Header wrapper must be initially hidden for an empty list so a fresh-load empty SC does not show an orphaned heading.'
+		);
+	}
+
+	/**
+	 * Invoke render() against the SUT (which already has a registry injected
+	 * in setUp). Render is wrapped in a try/catch because its downstream calls
+	 * (REST prefetch, interactivity bootstrap) need bits of the request
+	 * lifecycle that aren't set up in unit tests; the flag-setting branch
+	 * runs before any of that, so a later fatal doesn't change what we
+	 * assert on.
+	 *
+	 * @param array<string, mixed> $attributes Block attributes.
+	 * @return AssetDataRegistryMock The injected registry, ready to inspect.
+	 */
+	private function invoke_render_with_registry_mock( array $attributes ): AssetDataRegistryMock {
+		$render = new ReflectionMethod( SavedForLater::class, 'render' );
+		$render->setAccessible( true );
+
+		try {
+			$render->invoke( $this->sut, $attributes, '', null );
+		} catch ( \Throwable $e ) {
+			// Ignored: flag-setting runs before the parts of render() that need a full request lifecycle.
+			unset( $e );
+		}
+
+		$registry_prop = new ReflectionProperty( SavedForLater::class, 'asset_data_registry' );
+		$registry_prop->setAccessible( true );
+
+		return $registry_prop->getValue( $this->sut );
+	}
+
+	/**
+	 * @return array<string, array{bool, bool, bool}>
+	 */
+	public function provider_cart_page_has_saved_for_later_flag(): array {
+		return array(
+			// label                    => array( logged_in, is_cart, expected ).
+			'set on cart, logged-in'    => array( true, true, true ),
+			'not set off the cart page' => array( true, false, false ),
+			'not set for guests'        => array( false, true, false ),
+		);
+	}
+
+	/**
+	 * `cartPageHasSavedForLater` is the wcSettings flag the cart line item row reads
+	 * to decide whether to render the "Save for later" link. The block sets it
+	 * only when rendering the saved-for-later list, on the cart page, for a
+	 * logged-in shopper — every other combination must leave it unset.
+	 *
+	 * @dataProvider provider_cart_page_has_saved_for_later_flag
+	 *
+	 * @param bool $logged_in Whether the test runs as a logged-in customer.
+	 * @param bool $is_cart   Whether `is_cart()` is forced to return true.
+	 * @param bool $expected  Whether the flag is expected to be registered.
+	 */
+	public function test_cart_page_has_saved_for_later_flag(
+		bool $logged_in,
+		bool $is_cart,
+		bool $expected
+	): void {
+		wp_set_current_user( $logged_in ? self::factory()->user->create( array( 'role' => 'customer' ) ) : 0 );
+
+		// Mock the is_cart() call routed through LegacyProxy in render(). Filter/cache
+		// approaches don't work in CI: upstream tests can define `WOOCOMMERCE_CART` via
+		// `wc_maybe_define_constant`, which makes is_cart() short-circuit to true
+		// irreversibly for the rest of the process.
+		$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
+		$legacy_proxy->reset();
+		$legacy_proxy->register_function_mocks(
+			array(
+				'is_cart' => function () use ( $is_cart ) {
+					return $is_cart;
+				},
+			)
+		);
+
+		// This class extends WP_UnitTestCase rather than WC_Unit_Test_Case, so the
+		// proxy isn't reset automatically between tests — clean up explicitly to
+		// avoid leaking the is_cart() mock into later tests in the same process.
+		try {
+			$registry = $this->invoke_render_with_registry_mock( array() );
+
+			$this->assertSame(
+				$expected,
+				array_key_exists( 'cartPageHasSavedForLater', $registry->get() )
+			);
+		} finally {
+			$legacy_proxy->reset();
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php
new file mode 100644
index 00000000000..530e565f389
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php
@@ -0,0 +1,434 @@
+<?php
+/**
+ * Shopper Lists Route Tests.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+
+/**
+ * Tests for the /wc/store/v1/shopper-lists/* endpoints.
+ */
+class ShopperLists extends ControllerTestCase {
+
+	/**
+	 * Test product.
+	 *
+	 * @var \WC_Product
+	 */
+	private $product;
+
+	/**
+	 * Test customer user ID.
+	 *
+	 * @var int
+	 */
+	private $customer_id;
+
+	/**
+	 * Second test customer user ID, used to verify cross-user isolation.
+	 *
+	 * @var int
+	 */
+	private $other_customer_id;
+
+	/**
+	 * Setup test data.
+	 */
+	protected function setUp(): void {
+		// The shopper-lists routes are gated behind the `cart_save_for_later`
+		// feature flag, which is read inside `do_action( 'rest_api_init' )`
+		// fired by parent::setUp(). The option must be in place before then.
+		update_option( 'woocommerce_cart_save_for_later_enabled', 'yes' );
+
+		parent::setUp();
+
+		$fixtures      = new FixtureData();
+		$this->product = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Test Product',
+				'regular_price' => 10,
+			)
+		);
+
+		$this->customer_id       = $this->factory->user->create(
+			array(
+				'role'       => 'customer',
+				'user_email' => 'shopper-lists-1@test.com',
+			)
+		);
+		$this->other_customer_id = $this->factory->user->create(
+			array(
+				'role'       => 'customer',
+				'user_email' => 'shopper-lists-2@test.com',
+			)
+		);
+	}
+
+	/**
+	 * Tear down test data.
+	 */
+	protected function tearDown(): void {
+		parent::tearDown();
+
+		if ( $this->customer_id ) {
+			wp_delete_user( $this->customer_id );
+		}
+		if ( $this->other_customer_id ) {
+			wp_delete_user( $this->other_customer_id );
+		}
+
+		delete_option( 'woocommerce_cart_save_for_later_enabled' );
+	}
+
+	/**
+	 * Helper: dispatch a request and return the response.
+	 *
+	 * On writes, `$nonce` defaults to a valid `wc_store_api` nonce. Pass `''`
+	 * to omit the header, or any other string to send a bad one.
+	 *
+	 * @param string      $method HTTP method.
+	 * @param string      $route  Route path.
+	 * @param array       $params Body params.
+	 * @param string|null $nonce  Nonce override.
+	 * @return \WP_REST_Response
+	 */
+	private function dispatch( string $method, string $route, array $params = array(), ?string $nonce = null ): \WP_REST_Response {
+		$request = new \WP_REST_Request( $method, $route );
+		foreach ( $params as $key => $value ) {
+			$request->set_param( $key, $value );
+		}
+
+		$is_write = in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true );
+		if ( $is_write ) {
+			if ( null === $nonce ) {
+				$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+			} elseif ( '' !== $nonce ) {
+				$request->set_header( 'Nonce', $nonce );
+			}
+		}
+
+		return rest_get_server()->dispatch( $request );
+	}
+
+	/**
+	 * Helper: add the test product to the cart and return its cart_item_key.
+	 *
+	 * @return string
+	 */
+	private function add_product_to_cart(): string {
+		$key = wc()->cart->add_to_cart( $this->product->get_id(), 1 );
+		$this->assertNotEmpty( $key, 'add_to_cart should return a non-empty cart item key.' );
+		return (string) $key;
+	}
+
+	/**
+	 * Test that an unauthenticated request to GET /shopper-lists is rejected.
+	 */
+	public function test_get_lists_requires_login() {
+		wp_set_current_user( 0 );
+
+		$response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists' );
+
+		$this->assertContains( $response->get_status(), array( 401, 403 ), 'Unauthenticated requests must be rejected by the permission callback.' );
+	}
+
+	/**
+	 * Test that a logged-in user starts with saved-for-later auto-created and empty.
+	 */
+	public function test_get_lists_returns_save_for_later_for_logged_in_user() {
+		wp_set_current_user( $this->customer_id );
+
+		$response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists' );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertIsArray( $data );
+		$this->assertCount( 1, $data, 'Only saved-for-later is returned in v1.' );
+		$this->assertSame( 'saved-for-later', $data[0]['slug'] );
+		$this->assertSame( 0, $data[0]['item_count'] );
+	}
+
+	/**
+	 * Test that GET /shopper-lists/saved-for-later returns the list metadata.
+	 */
+	public function test_get_list_by_id_returns_save_for_later_metadata() {
+		wp_set_current_user( $this->customer_id );
+
+		$response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later' );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( 'saved-for-later', $data['slug'] );
+	}
+
+	/**
+	 * Test that GET /shopper-lists/{slug} returns 404 for any list other than saved-for-later.
+	 */
+	public function test_get_list_by_id_returns_404_for_unsupported_list() {
+		wp_set_current_user( $this->customer_id );
+
+		$response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/wishlist' );
+
+		$this->assertEquals( 404, $response->get_status() );
+	}
+
+	/**
+	 * Test POST /shopper-lists/saved-for-later/items with a real cart_item_key returns the saved item.
+	 */
+	public function test_post_item_via_cart_item_key() {
+		wp_set_current_user( $this->customer_id );
+
+		$cart_item_key = $this->add_product_to_cart();
+
+		$response = $this->dispatch(
+			'POST',
+			'/wc/store/v1/shopper-lists/saved-for-later/items',
+			array( 'cart_item_key' => $cart_item_key )
+		);
+		$data     = $response->get_data();
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertSame( $this->product->get_id(), $data['product_id'] );
+		$this->assertSame( 1, $data['quantity'], 'Saved quantity should mirror the cart line quantity.' );
+		$this->assertTrue( $data['is_live'] );
+		$this->assertSame( $this->product->get_title(), $data['name'] );
+		$this->assertNotEmpty( wc()->cart->cart_contents, 'Cart should still contain the line — POST is additive only.' );
+	}
+
+	/**
+	 * Test POST rejects requests without cart_item_key or product_id.
+	 */
+	public function test_post_item_requires_cart_item_key_or_product_id() {
+		wp_set_current_user( $this->customer_id );
+
+		$response = $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+
+		$this->assertEquals( 400, $response->get_status() );
+	}
+
+	/**
+	 * Test POST /shopper-lists/saved-for-later/items via direct product payload returns the saved item.
+	 */
+	public function test_post_item_via_manual_product_payload() {
+		wp_set_current_user( $this->customer_id );
+
+		$response = $this->dispatch(
+			'POST',
+			'/wc/store/v1/shopper-lists/saved-for-later/items',
+			array(
+				'product_id' => $this->product->get_id(),
+				'quantity'   => 2,
+			)
+		);
+		$data     = $response->get_data();
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertSame( $this->product->get_id(), $data['product_id'] );
+		$this->assertSame( 2, $data['quantity'], 'Posted quantity should be honored.' );
+	}
+
+	/**
+	 * Test POST rejects an unknown cart_item_key.
+	 */
+	public function test_post_item_unknown_cart_item_key_returns_404() {
+		wp_set_current_user( $this->customer_id );
+
+		$response = $this->dispatch(
+			'POST',
+			'/wc/store/v1/shopper-lists/saved-for-later/items',
+			array( 'cart_item_key' => 'thiskeydoesnotexist' )
+		);
+
+		$this->assertEquals( 404, $response->get_status() );
+	}
+
+	/**
+	 * Test POST returns 404 for any slug other than saved-for-later.
+	 */
+	public function test_post_item_unsupported_slug_returns_404() {
+		wp_set_current_user( $this->customer_id );
+		$cart_item_key = $this->add_product_to_cart();
+
+		$response = $this->dispatch(
+			'POST',
+			'/wc/store/v1/shopper-lists/wishlist/items',
+			array( 'cart_item_key' => $cart_item_key )
+		);
+
+		$this->assertEquals( 404, $response->get_status() );
+	}
+
+	/**
+	 * Test that adding the same cart line twice merges quantities into a single row.
+	 */
+	public function test_post_item_is_idempotent_for_same_cart_line() {
+		wp_set_current_user( $this->customer_id );
+		$cart_item_key = $this->add_product_to_cart();
+
+		$first  = $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+		$second = $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+
+		$this->assertEquals( 201, $first->get_status() );
+		$this->assertEquals( 201, $second->get_status() );
+		$this->assertSame( $first->get_data()['key'], $second->get_data()['key'], 'Same cart line should resolve to the same item key.' );
+		$this->assertSame( 2, $second->get_data()['quantity'], 'Repeating the same cart line must merge quantities.' );
+
+		$items_response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+		$this->assertCount( 1, $items_response->get_data(), 'Same cart line should not produce a duplicate row.' );
+	}
+
+	/**
+	 * Test that GET /items returns the saved items.
+	 */
+	public function test_get_items_returns_saved_items() {
+		wp_set_current_user( $this->customer_id );
+		$cart_item_key = $this->add_product_to_cart();
+
+		$this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+
+		$response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 1, $response->get_data() );
+	}
+
+	/**
+	 * Test that DELETE removes the item and returns 204 No Content.
+	 */
+	public function test_delete_item_removes_item() {
+		wp_set_current_user( $this->customer_id );
+		$cart_item_key = $this->add_product_to_cart();
+
+		$created = $this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+		$key     = $created->get_data()['key'];
+
+		$response = $this->dispatch( 'DELETE', '/wc/store/v1/shopper-lists/saved-for-later/items/' . $key );
+
+		$this->assertEquals( 204, $response->get_status() );
+		$this->assertNull( $response->get_data() );
+
+		$items_response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+		$this->assertCount( 0, $items_response->get_data(), 'List should be empty after the only item is deleted.' );
+	}
+
+	/**
+	 * Test that DELETE returns 404 when the item does not exist.
+	 */
+	public function test_delete_item_unknown_returns_404() {
+		wp_set_current_user( $this->customer_id );
+
+		// Auto-create the list so the route reaches the item-lookup branch.
+		$this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later' );
+
+		$nonexistent_key = str_repeat( 'a', 32 );
+		$response        = $this->dispatch( 'DELETE', '/wc/store/v1/shopper-lists/saved-for-later/items/' . $nonexistent_key );
+
+		$this->assertEquals( 404, $response->get_status() );
+	}
+
+	/**
+	 * Test that a logged-out user cannot delete via the route.
+	 */
+	public function test_delete_item_requires_login() {
+		wp_set_current_user( 0 );
+
+		$nonexistent_key = str_repeat( 'a', 32 );
+		$response        = $this->dispatch( 'DELETE', '/wc/store/v1/shopper-lists/saved-for-later/items/' . $nonexistent_key );
+
+		$this->assertContains( $response->get_status(), array( 401, 403 ) );
+	}
+
+	/**
+	 * Test that one user cannot see another user's items.
+	 */
+	public function test_users_lists_are_isolated() {
+		wp_set_current_user( $this->customer_id );
+		$cart_item_key = $this->add_product_to_cart();
+		$this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'cart_item_key' => $cart_item_key ) );
+
+		wp_set_current_user( $this->other_customer_id );
+		$response = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later/items' );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertCount( 0, $response->get_data(), 'Other user should not see the first user\'s items.' );
+	}
+
+	/**
+	 * Test writes without (or with invalid) Nonce header are rejected.
+	 *
+	 * @testWith ["POST", "", 401, "woocommerce_rest_missing_nonce"]
+	 *           ["POST", "not-a-valid-nonce", 403, "woocommerce_rest_invalid_nonce"]
+	 *           ["DELETE", "", 401, "woocommerce_rest_missing_nonce"]
+	 *           ["DELETE", "not-a-valid-nonce", 403, "woocommerce_rest_invalid_nonce"]
+	 *
+	 * @param string $method              HTTP method.
+	 * @param string $nonce               Nonce header value.
+	 * @param int    $expected_status     Expected HTTP status code.
+	 * @param string $expected_error_code Expected WP_Error code.
+	 */
+	public function test_write_nonce_enforcement( string $method, string $nonce, int $expected_status, string $expected_error_code ) {
+		wp_set_current_user( $this->customer_id );
+
+		$is_post = 'POST' === $method;
+		$path    = $is_post
+			? '/wc/store/v1/shopper-lists/saved-for-later/items'
+			: '/wc/store/v1/shopper-lists/saved-for-later/items/' . str_repeat( 'a', 32 );
+		$params  = $is_post ? array( 'product_id' => $this->product->get_id() ) : array();
+
+		$response = $this->dispatch( $method, $path, $params, $nonce );
+
+		$this->assertEquals( $expected_status, $response->get_status() );
+		$this->assertSame( $expected_error_code, $response->get_data()['code'] );
+	}
+
+	/**
+	 * Test every response (success or auth failure) refreshes the Nonce headers.
+	 *
+	 * @testWith [null, 201]
+	 *           ["", 401]
+	 *
+	 * @param string|null $nonce           Nonce header value, or null to auto-attach a valid one.
+	 * @param int         $expected_status Expected HTTP status code.
+	 */
+	public function test_response_refreshes_nonce_headers( ?string $nonce, int $expected_status ) {
+		wp_set_current_user( $this->customer_id );
+
+		$response = $this->dispatch(
+			'POST',
+			'/wc/store/v1/shopper-lists/saved-for-later/items',
+			array( 'product_id' => $this->product->get_id() ),
+			$nonce
+		);
+		$headers  = $response->get_headers();
+
+		$this->assertEquals( $expected_status, $response->get_status() );
+		$this->assertArrayHasKey( 'Nonce', $headers );
+		$this->assertArrayHasKey( 'Nonce-Timestamp', $headers );
+		$this->assertTrue( (bool) wp_verify_nonce( $headers['Nonce'], 'wc_store_api' ) );
+	}
+
+	/**
+	 * Test the `woocommerce_store_api_disable_nonce_check` filter bypass.
+	 */
+	public function test_disable_nonce_check_filter_bypasses_enforcement() {
+		wp_set_current_user( $this->customer_id );
+
+		add_filter( 'woocommerce_store_api_disable_nonce_check', '__return_true' );
+		try {
+			$response = $this->dispatch(
+				'POST',
+				'/wc/store/v1/shopper-lists/saved-for-later/items',
+				array( 'product_id' => $this->product->get_id() ),
+				''
+			);
+		} finally {
+			remove_filter( 'woocommerce_store_api_disable_nonce_check', '__return_true' );
+		}
+
+		$this->assertEquals( 201, $response->get_status() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php
new file mode 100644
index 00000000000..89d9406675a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php
@@ -0,0 +1,496 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Schemas;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Formatters;
+use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\ShopperListItemSchema;
+use WC_Unit_Test_Case;
+
+/**
+ * ShopperListItemSchemaTest class.
+ */
+class ShopperListItemSchemaTest extends WC_Unit_Test_Case {
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ShopperListItemSchema
+	 */
+	private $sut;
+
+	/**
+	 * ExtendSchema instance.
+	 *
+	 * @var ExtendSchema
+	 */
+	private $extend;
+
+	/**
+	 * SchemaController instance.
+	 *
+	 * @var SchemaController
+	 */
+	private $schema_controller;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$formatters = new Formatters();
+		$formatters->register( 'money', MoneyFormatter::class );
+		$formatters->register( 'html', HtmlFormatter::class );
+		$formatters->register( 'currency', CurrencyFormatter::class );
+
+		$this->extend            = new ExtendSchema( $formatters );
+		$this->schema_controller = new SchemaController( $this->extend );
+		$this->sut               = new ShopperListItemSchema( $this->extend, $this->schema_controller );
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		$this->sut               = null;
+		$this->extend            = null;
+		$this->schema_controller = null;
+	}
+
+	/**
+	 * Build a ShopperListItem around a product.
+	 *
+	 * @param int    $product_id   Product ID.
+	 * @param int    $variation_id Variation ID, or 0.
+	 * @param array  $variation    Variation attributes.
+	 * @param string $title        Title snapshot.
+	 */
+	private function build_item( int $product_id, int $variation_id = 0, array $variation = array(), string $title = 'Snapshot Title' ): ShopperListItem {
+		return ShopperListItem::from_array(
+			array(
+				'key'                   => md5( (string) $product_id ),
+				'product_id'            => $product_id,
+				'variation_id'          => $variation_id,
+				'variation'             => $variation,
+				'quantity'              => 1,
+				'date_added_gmt'        => '2024-04-25 03:20:00',
+				'product_title_at_save' => $title,
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should serve live product data and is_live=true for a published product.
+	 */
+	public function test_returns_live_data_when_is_live(): void {
+		$product = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Live T-Shirt',
+				'regular_price' => 19.99,
+			)
+		);
+
+		$response = $this->sut->get_item_response( $this->build_item( $product->get_id(), 0, array(), 'Snapshot T-Shirt' ) );
+
+		$this->assertTrue( $response['is_live'], 'is_live must be true for a published product' );
+		$this->assertTrue( $response['is_purchasable'], 'A published, in-stock priced product is purchasable' );
+		$this->assertSame( 'Live T-Shirt', $response['name'], 'Live name should be served, not the snapshot' );
+		$this->assertSame( $product->get_permalink(), $response['permalink'] );
+		$this->assertNotNull( $response['prices'], 'Live prices should be populated' );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox is_live should gate on publish status (self and parent). Stock, catalog visibility, and post password don't affect it.
+	 * @dataProvider provider_is_live_cases
+	 *
+	 * @param array<string, string> $overrides     Post/product overrides to apply.
+	 * @param bool                  $expected_live Expected is_live value.
+	 */
+	public function test_is_live_predicate( array $overrides, bool $expected_live ): void {
+		$product = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Subject',
+				'regular_price' => 19.99,
+			)
+		);
+
+		if ( isset( $overrides['stock_status'] ) ) {
+			$product->set_stock_status( $overrides['stock_status'] );
+			$product->save();
+		}
+		if ( isset( $overrides['catalog_visibility'] ) ) {
+			$product->set_catalog_visibility( $overrides['catalog_visibility'] );
+			$product->save();
+		}
+		$post_overrides = array_intersect_key( $overrides, array_flip( array( 'post_status', 'post_password' ) ) );
+		if ( ! empty( $post_overrides ) ) {
+			// `wp_update_post` silently rewrites `future` back to `publish` when post_date is in the past,
+			// so a future date is needed to actually persist the status.
+			if ( 'future' === ( $post_overrides['post_status'] ?? '' ) ) {
+				$post_overrides['post_date_gmt'] = gmdate( 'Y-m-d H:i:s', strtotime( '+1 year' ) );
+				$post_overrides['post_date']     = $post_overrides['post_date_gmt'];
+			}
+			wp_update_post( array_merge( array( 'ID' => $product->get_id() ), $post_overrides ) );
+		}
+
+		$response = $this->sut->get_item_response( $this->build_item( $product->get_id(), 0, array(), 'Snapshot Title' ) );
+
+		$this->assertSame( $expected_live, $response['is_live'] );
+		if ( ! $expected_live ) {
+			$this->assertFalse( $response['is_purchasable'], 'Non-public products are not purchasable' );
+			$this->assertSame( 'Snapshot Title', $response['name'], 'Tombstone must not leak the live title' );
+			$this->assertNull( $response['permalink'], 'Tombstone permalink must be null so iAPI strips the anchor href' );
+			$this->assertNull( $response['prices'], 'Tombstone must not leak live prices' );
+		}
+
+		wp_delete_post( $product->get_id(), true );
+	}
+
+	/**
+	 * @return array<string, array{0: array<string, string>, 1: bool}>
+	 */
+	public function provider_is_live_cases(): array {
+		return array(
+			// Renderable — guards against using `is_visible()`, which would
+			// tombstone deliberately-saved OOS / catalog-hidden items.
+			'OOS, publish'              => array( array( 'stock_status' => 'outofstock' ), true ),
+			'catalog_visibility=hidden' => array( array( 'catalog_visibility' => 'hidden' ), true ),
+			// Tombstone cases.
+			'draft'                     => array( array( 'post_status' => 'draft' ), false ),
+			'pending'                   => array( array( 'post_status' => 'pending' ), false ),
+			'private'                   => array( array( 'post_status' => 'private' ), false ),
+			'trash'                     => array( array( 'post_status' => 'trash' ), false ),
+			'future'                    => array( array( 'post_status' => 'future' ), false ),
+		);
+	}
+
+	/**
+	 * @testdox Out-of-stock products stay live but aren't purchasable, matching catalog behavior.
+	 */
+	public function test_out_of_stock_product_is_live_but_not_purchasable(): void {
+		$product = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'OOS T-Shirt',
+				'regular_price' => 19.99,
+			)
+		);
+		$product->set_stock_status( 'outofstock' );
+		$product->save();
+
+		$response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+		$this->assertTrue( $response['is_live'], 'OOS products stay renderable' );
+		$this->assertFalse( $response['is_purchasable'], 'Cart button hidden for OOS, mirroring the storefront catalog gate (is_purchasable() && is_in_stock())' );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Password-protected products stay live (clickable) but aren't purchasable.
+	 */
+	public function test_password_protected_product_is_live_but_not_purchasable(): void {
+		$product = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Gated T-Shirt',
+				'regular_price' => 19.99,
+			)
+		);
+		wp_update_post(
+			array(
+				'ID'            => $product->get_id(),
+				'post_password' => 'secret',
+			)
+		);
+
+		$response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+		$this->assertTrue( $response['is_live'], 'Page renders behind a password prompt, so the row stays clickable' );
+		$this->assertFalse( $response['is_purchasable'], 'Customer must authenticate before purchasing' );
+		$this->assertSame( $product->get_permalink(), $response['permalink'] );
+
+		wp_delete_post( $product->get_id(), true );
+	}
+
+	/**
+	 * @testdox Admins viewing their own draft products see a tombstone, not a Move-to-cart button.
+	 */
+	public function test_admin_viewing_own_draft_does_not_short_circuit_is_purchasable(): void {
+		$admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin_id );
+
+		$product = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Pre-launch T-Shirt',
+				'regular_price' => 19.99,
+			)
+		);
+		wp_update_post(
+			array(
+				'ID'          => $product->get_id(),
+				'post_status' => 'draft',
+			)
+		);
+
+		$response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+		// WC_Product::is_purchasable() short-circuits via `current_user_can( 'edit_post', ... )`
+		// for admins. Our predicate must gate on is_live first so the tombstone row isn't
+		// rendered with a working cart button.
+		$this->assertFalse( $response['is_live'] );
+		$this->assertFalse( $response['is_purchasable'], 'Admin escape hatch in WC_Product::is_purchasable() must not leak into is_purchasable on a tombstone row.' );
+
+		wp_delete_post( $product->get_id(), true );
+		wp_delete_user( $admin_id );
+	}
+
+	/**
+	 * @testdox Should tombstone a variation whose parent is not published.
+	 */
+	public function test_variation_with_non_publish_parent_is_tombstoned(): void {
+		$variable = \WC_Helper_Product::create_variation_product();
+		$children = $variable->get_children();
+		$this->assertNotEmpty( $children, 'Variable product helper should produce variation children' );
+		$variation_id = (int) $children[0];
+
+		wp_update_post(
+			array(
+				'ID'          => $variable->get_id(),
+				'post_status' => 'draft',
+			)
+		);
+
+		$response = $this->sut->get_item_response(
+			$this->build_item( $variable->get_id(), $variation_id, array(), 'Snapshot Title' )
+		);
+
+		$this->assertFalse( $response['is_live'], 'Variations under a non-publish parent must be tombstoned' );
+		$this->assertSame( 'Snapshot Title', $response['name'] );
+
+		$variable->delete( true );
+	}
+
+	/**
+	 * @testdox Variations under a password-protected parent stay live but aren't purchasable.
+	 */
+	public function test_variation_with_password_protected_parent_is_not_purchasable(): void {
+		$variable = \WC_Helper_Product::create_variation_product();
+		$children = $variable->get_children();
+		$this->assertNotEmpty( $children, 'Variable product helper should produce variation children' );
+		$variation_id = (int) $children[0];
+
+		wp_update_post(
+			array(
+				'ID'            => $variable->get_id(),
+				'post_password' => 'secret',
+			)
+		);
+
+		$response = $this->sut->get_item_response(
+			$this->build_item( $variable->get_id(), $variation_id, array(), 'Snapshot Title' )
+		);
+
+		$this->assertTrue( $response['is_live'], 'Parent still renders behind a password prompt' );
+		$this->assertFalse( $response['is_purchasable'], 'Parent password must gate the variation too' );
+
+		$variable->delete( true );
+	}
+
+	/**
+	 * @testdox Should expose price_html for live products and an empty string for tombstones.
+	 */
+	public function test_price_html_is_populated_for_live_products(): void {
+		$product = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Priced T-Shirt',
+				'regular_price' => 19.99,
+			)
+		);
+
+		$response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+		$this->assertArrayHasKey( 'price_html', $response, 'price_html must be present on the response' );
+		$this->assertIsString( $response['price_html'] );
+		$this->assertNotSame( '', $response['price_html'], 'price_html must be non-empty for a live priced product' );
+		$this->assertStringContainsString( 'woocommerce-Price-amount', $response['price_html'], 'price_html should be the formatted markup from wc_price' );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Should expose image_html with the configured product thumbnail when one is set.
+	 */
+	public function test_image_html_uses_product_thumbnail_when_available(): void {
+		$attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
+		$product       = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Imaged T-Shirt' ) );
+		$product->set_image_id( $attachment_id );
+		$product->save();
+
+		$response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+		$this->assertArrayHasKey( 'image_html', $response, 'image_html must be present on the response' );
+		$this->assertIsString( $response['image_html'] );
+		$this->assertStringContainsString( '<img', $response['image_html'], 'image_html must be a fully-formed <img> element' );
+		$this->assertStringContainsString( 'srcset=', $response['image_html'], 'image_html must carry the responsive srcset attribute' );
+
+		$product->delete( true );
+		wp_delete_attachment( $attachment_id, true );
+	}
+
+	/**
+	 * @testdox Should expose image_html using the WooCommerce placeholder when the product has no image.
+	 */
+	public function test_image_html_falls_back_to_placeholder_when_product_has_no_image(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'No-Image T-Shirt' ) );
+		$product->set_image_id( 0 );
+		$product->save();
+
+		$response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+		$this->assertArrayHasKey( 'image_html', $response );
+		$this->assertSame(
+			(string) wc_placeholder_img( 'woocommerce_thumbnail' ),
+			$response['image_html'],
+			'image_html for an image-less product must equal the configured placeholder markup'
+		);
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Should fall back to at-save snapshot data when the product no longer exists.
+	 */
+	public function test_falls_back_to_snapshot_when_product_missing(): void {
+		$product    = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'About to be Deleted' ) );
+		$product_id = $product->get_id();
+		$item       = $this->build_item( $product_id, 0, array(), 'Snapshot Title' );
+		wp_delete_post( $product_id, true );
+
+		$response = $this->sut->get_item_response( $item );
+
+		$this->assertFalse( $response['is_live'], 'is_live must be false when the product is gone' );
+		$this->assertSame( 'Snapshot Title', $response['name'], 'Tombstone name should fall back to the at-save title snapshot' );
+		$this->assertNull( $response['permalink'], 'Tombstone permalink must be null so iAPI strips the anchor href' );
+		$this->assertSame( array(), $response['images'], 'No images should be returned for missing products' );
+		$this->assertNull( $response['prices'], 'Live prices should be null for missing products' );
+		$this->assertArrayNotHasKey( 'product_title_at_save', $response, 'Internal at-save title snapshot should not leak into the public response' );
+	}
+
+	/**
+	 * @testdox Should expose an empty price_html and the placeholder image_html for tombstones.
+	 */
+	public function test_tombstone_returns_empty_price_html_and_placeholder_image_html(): void {
+		$product    = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Going Away',
+				'regular_price' => 24.99,
+			)
+		);
+		$product_id = $product->get_id();
+		$item       = $this->build_item( $product_id, 0, array(), 'Going Away' );
+		wp_delete_post( $product_id, true );
+
+		$response = $this->sut->get_item_response( $item );
+
+		$this->assertArrayHasKey( 'price_html', $response );
+		$this->assertSame( '', $response['price_html'], 'Tombstones must not advertise a price' );
+		$this->assertArrayHasKey( 'image_html', $response );
+		$this->assertSame(
+			(string) wc_placeholder_img( 'woocommerce_thumbnail' ),
+			$response['image_html'],
+			'Tombstones must use the placeholder image markup'
+		);
+	}
+
+	/**
+	 * @testdox Should expose key on a live-product response.
+	 */
+	public function test_response_carries_the_item_key(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$item    = $this->build_item( $product->get_id() );
+
+		$response = $this->sut->get_item_response( $item );
+
+		$this->assertArrayHasKey( 'key', $response );
+		$this->assertSame( $item->get_key(), $response['key'], 'Response key must match the saved item key.' );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Should format date_added_gmt via wc_rest_prepare_date_response (ISO8601, no timezone suffix).
+	 */
+	public function test_date_added_gmt_is_iso8601(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$response = $this->sut->get_item_response( $this->build_item( $product->get_id() ) );
+
+		$this->assertArrayHasKey( 'date_added_gmt', $response );
+		$this->assertSame( '2024-04-25T03:20:00', $response['date_added_gmt'], 'date_added_gmt must be the ISO8601 formatting of the GMT save time.' );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Should expose the variation ID as id when the saved item is a variation.
+	 */
+	public function test_id_resolves_to_variation_id_for_variations(): void {
+		$variable_product = \WC_Helper_Product::create_variation_product();
+		$variation_ids    = $variable_product->get_children();
+		$variation_id     = (int) $variation_ids[0];
+		$item             = $this->build_item( $variable_product->get_id(), $variation_id );
+
+		$response = $this->sut->get_item_response( $item );
+
+		$this->assertArrayHasKey( 'id', $response );
+		$this->assertSame( $variation_id, $response['id'], 'id should resolve to variation_id when set.' );
+		$this->assertArrayHasKey( 'product_id', $response );
+		$this->assertSame( $variable_product->get_id(), $response['product_id'], 'product_id should still hold the parent product id.' );
+		$this->assertArrayHasKey( 'variation_id', $response );
+		$this->assertSame( $variation_id, $response['variation_id'] );
+
+		$variable_product->delete( true );
+	}
+
+	/**
+	 * @testdox Should format saved variation attributes via format_variation_data on live variations.
+	 */
+	public function test_variation_attributes_are_formatted_on_live_variations(): void {
+		$variable_product = \WC_Helper_Product::create_variation_product();
+		$variation_id     = (int) $variable_product->get_children()[0];
+		$item             = $this->build_item(
+			$variable_product->get_id(),
+			$variation_id,
+			array( 'attribute_pa_size' => 'small' )
+		);
+
+		$response = $this->sut->get_item_response( $item );
+
+		$this->assertArrayHasKey( 'variation', $response );
+		$this->assertIsArray( $response['variation'] );
+		$this->assertCount( 1, $response['variation'], 'A single saved attribute should produce one entry in the variation list.' );
+
+		$entry = $response['variation'][0];
+		$this->assertArrayHasKey( 'raw_attribute', $entry );
+		$this->assertArrayHasKey( 'attribute', $entry );
+		$this->assertArrayHasKey( 'value', $entry );
+		$this->assertSame( 'attribute_pa_size', $entry['raw_attribute'] );
+
+		$variable_product->delete( true );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListSchemaTest.php
new file mode 100644
index 00000000000..d878769c999
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListSchemaTest.php
@@ -0,0 +1,175 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Schemas;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\StoreApi\Formatters;
+use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\ShopperListSchema;
+use WC_Unit_Test_Case;
+
+/**
+ * ShopperListSchemaTest class.
+ */
+class ShopperListSchemaTest extends WC_Unit_Test_Case {
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ShopperListSchema
+	 */
+	private $sut;
+
+	/**
+	 * Test user ID.
+	 *
+	 * @var int
+	 */
+	private $user_id;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		// `saved-for-later` depends on the `cart_save_for_later` feature
+		// flag. Filter the option read so `ShopperList::get_by_slug()`
+		// returns a list without writing the option to the database.
+		add_filter( 'pre_option_woocommerce_cart_save_for_later_enabled', array( $this, 'filter_save_for_later_enabled' ) );
+
+		parent::setUp();
+
+		$formatters = new Formatters();
+		$formatters->register( 'money', MoneyFormatter::class );
+		$formatters->register( 'html', HtmlFormatter::class );
+		$formatters->register( 'currency', CurrencyFormatter::class );
+
+		$extend            = new ExtendSchema( $formatters );
+		$schema_controller = new SchemaController( $extend );
+		$this->sut         = new ShopperListSchema( $extend, $schema_controller );
+		$this->user_id     = $this->factory->user->create( array( 'role' => 'customer' ) );
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		wp_delete_user( $this->user_id );
+		$this->sut = null;
+		remove_filter( 'pre_option_woocommerce_cart_save_for_later_enabled', array( $this, 'filter_save_for_later_enabled' ) );
+		parent::tearDown();
+	}
+
+	/**
+	 * Filter callback that forces the SFL option to `yes` for the lifetime of the test.
+	 */
+	public function filter_save_for_later_enabled(): string {
+		return 'yes';
+	}
+
+	/**
+	 * Build an empty saved-for-later list for the test user.
+	 */
+	private function build_list(): ShopperList {
+		return ShopperList::get_by_slug( 'saved-for-later', $this->user_id );
+	}
+
+	/**
+	 * Build a minimal ShopperListItem around a product ID.
+	 *
+	 * @param int $product_id Product ID.
+	 */
+	private function build_item( int $product_id ): ShopperListItem {
+		return ShopperListItem::from_array(
+			array(
+				'key'                   => md5( (string) $product_id ),
+				'product_id'            => $product_id,
+				'variation_id'          => 0,
+				'variation'             => array(),
+				'quantity'              => 1,
+				'date_added_gmt'        => '2024-04-25 03:20:00',
+				'product_title_at_save' => 'Snapshot',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should expose slug, item_count, date_created_gmt and a 0-indexed items array.
+	 */
+	public function test_serializes_top_level_fields(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$list    = $this->build_list();
+		$list->add_item( $this->build_item( $product->get_id() ) );
+
+		$response = $this->sut->get_item_response( $list );
+
+		$this->assertArrayHasKey( 'slug', $response );
+		$this->assertSame( 'saved-for-later', $response['slug'] );
+
+		$this->assertArrayHasKey( 'item_count', $response );
+		$this->assertSame( 1, $response['item_count'] );
+
+		$this->assertArrayHasKey( 'items', $response );
+		$this->assertIsArray( $response['items'] );
+		$this->assertSame( array( 0 ), array_keys( $response['items'] ), 'items must be a 0-indexed list, not keyed by storage key.' );
+
+		$this->assertArrayHasKey( 'date_created_gmt', $response );
+		$this->assertIsString( $response['date_created_gmt'] );
+		$this->assertNotSame( '', $response['date_created_gmt'] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox item_count should include items whose products no longer exist.
+	 */
+	public function test_item_count_includes_tombstones(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$list    = $this->build_list();
+		$list->add_item( $this->build_item( $product->get_id() ) );
+		$list->add_item( $this->build_item( 999999 ) );
+
+		$response = $this->sut->get_item_response( $list );
+
+		$this->assertSame( 2, $response['item_count'], 'item_count must count tombstoned items, not just live products.' );
+		$this->assertCount( 2, $response['items'] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Empty list should serialize with item_count=0 and items=[].
+	 */
+	public function test_empty_list(): void {
+		$list = $this->build_list();
+
+		$response = $this->sut->get_item_response( $list );
+
+		$this->assertSame( 0, $response['item_count'] );
+		$this->assertSame( array(), $response['items'] );
+	}
+
+	/**
+	 * @testdox Each item should carry the keys produced by the item schema.
+	 */
+	public function test_items_use_item_schema_shape(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$list    = $this->build_list();
+		$list->add_item( $this->build_item( $product->get_id() ) );
+
+		$response = $this->sut->get_item_response( $list );
+
+		$item = $response['items'][0];
+		$this->assertArrayHasKey( 'key', $item );
+		$this->assertArrayHasKey( 'product_id', $item );
+		$this->assertArrayHasKey( 'quantity', $item );
+		$this->assertArrayHasKey( 'is_live', $item );
+		$this->assertTrue( $item['is_live'], 'Live product items should have is_live=true.' );
+
+		$product->delete( true );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListItemTests.php b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListItemTests.php
new file mode 100644
index 00000000000..4f1479dc6e9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListItemTests.php
@@ -0,0 +1,153 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for ShopperListItem.
+ */
+class ShopperListItemTests extends WC_Unit_Test_Case {
+	/**
+	 * @var \WC_Product
+	 */
+	private $product;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->product = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Item SUT Product',
+				'regular_price' => 19.99,
+			)
+		);
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		if ( $this->product ) {
+			$this->product->delete( true );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox from_product should build an item from a live product, snapshotting the title.
+	 */
+	public function test_from_product_builds_item_from_live_product(): void {
+		$item = ShopperListItem::from_product( $this->product->get_id(), array(), 3 );
+
+		$this->assertInstanceOf( ShopperListItem::class, $item );
+		$arr = $item->to_array();
+		$this->assertSame( $this->product->get_title(), $arr['product_title_at_save'] );
+		$this->assertSame( 3, $arr['quantity'], 'Quantity should reflect the value passed to from_product.' );
+	}
+
+	/**
+	 * @testdox from_product should default quantity to 1 and coerce zero/negative values up to 1.
+	 */
+	public function test_from_product_normalizes_quantity_floor(): void {
+		$default = ShopperListItem::from_product( $this->product->get_id() );
+		$this->assertInstanceOf( ShopperListItem::class, $default );
+		$this->assertSame( 1, $default->to_array()['quantity'] );
+
+		$zero = ShopperListItem::from_product( $this->product->get_id(), array(), 0 );
+		$this->assertInstanceOf( ShopperListItem::class, $zero );
+		$this->assertSame( 1, $zero->to_array()['quantity'] );
+
+		$negative = ShopperListItem::from_product( $this->product->get_id(), array(), -5 );
+		$this->assertInstanceOf( ShopperListItem::class, $negative );
+		$this->assertSame( 1, $negative->to_array()['quantity'] );
+	}
+
+	/**
+	 * @testdox from_product should return null when the product can't be resolved.
+	 */
+	public function test_from_product_returns_null_for_missing_product(): void {
+		$this->assertNull( ShopperListItem::from_product( 99999999 ) );
+	}
+
+	/**
+	 * @testdox to_array round-trips through from_array.
+	 */
+	public function test_round_trips_through_from_array(): void {
+		$original = ShopperListItem::from_product( $this->product->get_id() );
+		$rebuilt  = ShopperListItem::from_array( $original->to_array() );
+
+		$this->assertSame( $original->to_array(), $rebuilt->to_array() );
+	}
+
+	/**
+	 * @testdox from_product validates the variation array against the variation product, like cart does.
+	 */
+	public function test_from_variation_validates_against_variation_product(): void {
+		$variable = \WC_Helper_Product::create_variation_product();
+
+		$find = function ( array $attrs ) use ( $variable ): int {
+			foreach ( $variable->get_children() as $variation_id ) {
+				$expected = wc_get_product_variation_attributes( (int) $variation_id );
+				if ( empty( array_diff_assoc( $attrs, $expected ) ) ) {
+					return (int) $variation_id;
+				}
+			}
+			$this->fail( 'No variation matched the requested attribute set.' );
+		};
+
+		$all_specific = $find(
+			array(
+				'attribute_pa_size'   => 'huge',
+				'attribute_pa_colour' => 'red',
+				'attribute_pa_number' => '0',
+			)
+		);
+		$any_number   = $find(
+			array(
+				'attribute_pa_size'   => 'huge',
+				'attribute_pa_colour' => 'blue',
+				'attribute_pa_number' => '',
+			)
+		);
+
+		// Specific attrs: server fills them in even when the caller passes nothing.
+		$variation = ShopperListItem::from_product( $all_specific, array() )->to_array()['variation'];
+		$this->assertSame( 'huge', $variation['attribute_pa_size'] );
+		$this->assertSame( 'red', $variation['attribute_pa_colour'] );
+		$this->assertSame( '0', $variation['attribute_pa_number'] );
+
+		// Specific attrs: client value mismatching the variation is rejected.
+		try {
+			ShopperListItem::from_product( $all_specific, array( 'attribute_pa_colour' => 'blue' ) );
+			$this->fail( 'Expected mismatched specific value to throw.' );
+		} catch ( \InvalidArgumentException $e ) {
+			$this->addToAssertionCount( 1 );
+		}
+
+		// "Any" slot: missing client value is rejected.
+		try {
+			ShopperListItem::from_product( $any_number, array() );
+			$this->fail( 'Expected missing any-slot value to throw.' );
+		} catch ( \InvalidArgumentException $e ) {
+			$this->addToAssertionCount( 1 );
+		}
+
+		// "Any" slot: a value present on the parent is accepted and stored.
+		$variation = ShopperListItem::from_product( $any_number, array( 'attribute_pa_number' => '2' ) )->to_array()['variation'];
+		$this->assertSame( '2', $variation['attribute_pa_number'] );
+
+		// "Any" slot: a value not in the parent's slugs is rejected.
+		try {
+			ShopperListItem::from_product( $any_number, array( 'attribute_pa_number' => '99' ) );
+			$this->fail( 'Expected invalid any-slot value to throw.' );
+		} catch ( \InvalidArgumentException $e ) {
+			$this->addToAssertionCount( 1 );
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListTests.php b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListTests.php
new file mode 100644
index 00000000000..7f59e91010c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListTests.php
@@ -0,0 +1,200 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListItem;
+use Automattic\WooCommerce\Internal\Utilities\Users;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for ShopperList.
+ */
+class ShopperListTests extends WC_Unit_Test_Case {
+	private const SAVED_FOR_LATER_SLUG = 'saved-for-later';
+
+	/**
+	 * Map of shopper-list slug => feature option key.
+	 */
+	private const LIST_OPTIONS = array(
+		'saved-for-later' => 'woocommerce_cart_save_for_later_enabled',
+		'wishlist'        => 'woocommerce_product_wishlist_enabled',
+	);
+
+	/**
+	 * @var int
+	 */
+	private $user_id;
+
+	/**
+	 * @var \WC_Product
+	 */
+	private $product;
+
+	/**
+	 * @var ShopperListItem
+	 */
+	private $item;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->enable_list( self::SAVED_FOR_LATER_SLUG );
+
+		$this->user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$this->product = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'List SUT Product',
+				'regular_price' => 19.99,
+			)
+		);
+		$this->item    = ShopperListItem::from_product( $this->product->get_id() );
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		if ( $this->product ) {
+			$this->product->delete( true );
+		}
+		foreach ( array_keys( self::LIST_OPTIONS ) as $slug ) {
+			$this->disable_list( $slug );
+		}
+		delete_option( 'woocommerce_queue_flush_rewrite_rules' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Enable the feature backing the given shopper-list slug.
+	 *
+	 * @param string $slug List slug.
+	 */
+	private function enable_list( string $slug ): void {
+		update_option( self::LIST_OPTIONS[ $slug ], 'yes' );
+	}
+
+	/**
+	 * Disable the feature backing the given shopper-list slug.
+	 *
+	 * @param string $slug List slug.
+	 */
+	private function disable_list( string $slug ): void {
+		update_option( self::LIST_OPTIONS[ $slug ], 'no' );
+	}
+
+	/**
+	 * @testdox saved-for-later is in-memory only on first read and is persisted lazily on the first add_item()+save().
+	 */
+	public function test_save_for_later_persistence_is_lazy(): void {
+		$list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+
+		$this->assertInstanceOf( ShopperList::class, $list );
+		$this->assertSame( self::SAVED_FOR_LATER_SLUG, $list->get_slug() );
+		$this->assertSame( array(), $list->get_items() );
+
+		$meta_key = ShopperList::META_KEY_PREFIX . self::SAVED_FOR_LATER_SLUG;
+		$this->assertSame( '', Users::get_site_user_meta( $this->user_id, $meta_key ), 'Empty saved-for-later should not be persisted before the first add.' );
+
+		$list->add_item( $this->item );
+		$list->save();
+
+		$this->assertIsArray( Users::get_site_user_meta( $this->user_id, $meta_key ), 'saved-for-later should be persisted after the first add+save.' );
+	}
+
+	/**
+	 * @testdox get_by_slug should return false for any disabled or unknown list slug.
+	 */
+	public function test_load_returns_false_for_unsupported_list_slug(): void {
+		// `wishlist` is a known slug but its feature is off in this test class.
+		$this->assertFalse( ShopperList::get_by_slug( 'wishlist', $this->user_id ) );
+		$this->assertFalse( ShopperList::get_by_slug( 'INVALID', $this->user_id ) );
+		$this->assertFalse( ShopperList::get_by_slug( '', $this->user_id ) );
+	}
+
+	/**
+	 * @testdox get_by_slug should return false when the feature is disabled, even when the list has persisted items.
+	 */
+	public function test_load_returns_false_when_feature_disabled_for_persisted_list(): void {
+		// Persist a list while the feature is on.
+		$list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+		$list->add_item( $this->item );
+		$list->save();
+		$meta_key = ShopperList::META_KEY_PREFIX . self::SAVED_FOR_LATER_SLUG;
+		$this->assertIsArray( Users::get_site_user_meta( $this->user_id, $meta_key ) );
+
+		// Disable the feature; the persisted list must no longer be returned.
+		$this->disable_list( self::SAVED_FOR_LATER_SLUG );
+		$this->assertFalse( ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id ) );
+	}
+
+	/**
+	 * @testdox get_by_slug should self-heal saved-for-later when the stored meta is corrupt.
+	 */
+	public function test_load_self_heals_corrupt_saved_for_later(): void {
+		Users::update_site_user_meta(
+			$this->user_id,
+			ShopperList::META_KEY_PREFIX . self::SAVED_FOR_LATER_SLUG,
+			'this-is-not-an-array'
+		);
+
+		$list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+
+		$this->assertInstanceOf( ShopperList::class, $list );
+		$this->assertSame( array(), $list->get_items(), 'Corrupt meta must yield an empty in-memory list.' );
+	}
+
+	/**
+	 * @testdox get_by_slug should skip individual corrupt items but still return the rest of the list.
+	 */
+	public function test_load_skips_corrupt_items(): void {
+		$good_item = $this->item->to_array();
+		Users::update_site_user_meta(
+			$this->user_id,
+			ShopperList::META_KEY_PREFIX . self::SAVED_FOR_LATER_SLUG,
+			array(
+				'slug'             => self::SAVED_FOR_LATER_SLUG,
+				'date_created_gmt' => '2026-04-01 00:00:00',
+				'items'            => array(
+					$good_item['key'] => $good_item,
+					// Missing key + product_id.
+					'broken-row-key'  => array( 'variation_id' => 0 ),
+				),
+			)
+		);
+
+		$list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+
+		$this->assertInstanceOf( ShopperList::class, $list );
+		$this->assertCount( 1, $list->get_items(), 'Bad rows should be skipped, the rest kept.' );
+		$this->assertNotNull( $list->find_item( $good_item['key'] ) );
+	}
+
+	/**
+	 * @testdox add_item/remove_item round-trip through save() and reload, merge quantities for the same key, and report unknown keys.
+	 */
+	public function test_list_item_crud(): void {
+		$list = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+
+		$list->add_item( $this->item );
+		$list->add_item( $this->item );
+		$list->save();
+
+		$reloaded = ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id );
+		$this->assertCount( 1, $reloaded->get_items(), 'Adding the same item twice must keep a single row.' );
+
+		$merged = $reloaded->find_item( $this->item->get_key() );
+		$this->assertNotNull( $merged );
+		$this->assertSame( 2, $merged->get_quantity(), 'Quantities must be summed when the same product+variation is added again.' );
+
+		$this->assertFalse( $reloaded->remove_item( 'nonexistent-key' ), 'remove_item should return false for unknown keys.' );
+		$this->assertTrue( $reloaded->remove_item( $this->item->get_key() ) );
+		$reloaded->save();
+
+		$this->assertSame( array(), ShopperList::get_by_slug( self::SAVED_FOR_LATER_SLUG, $this->user_id )->get_items() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListsControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListsControllerTests.php
new file mode 100644
index 00000000000..c294e37955f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ShopperLists/ShopperListsControllerTests.php
@@ -0,0 +1,292 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ShopperLists;
+
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for ShopperListsController.
+ */
+class ShopperListsControllerTests extends WC_Unit_Test_Case {
+
+	private const FLUSH_QUEUE_OPTION = 'woocommerce_queue_flush_rewrite_rules';
+
+	/**
+	 * Map of shopper-list slug => feature option key.
+	 */
+	private const LIST_OPTIONS = array(
+		'saved-for-later' => 'woocommerce_cart_save_for_later_enabled',
+		'wishlist'        => 'woocommerce_product_wishlist_enabled',
+	);
+
+	/**
+	 * System under test.
+	 *
+	 * @var ShopperListsController|null
+	 */
+	private $sut;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		// Direct construction (not via container) keeps each test isolated:
+		// the controller has no constructor deps, and hooks attached by one
+		// test don't leak into the shared container instance.
+		$this->sut = new ShopperListsController();
+		// Start every test with all features off; individual tests opt in.
+		foreach ( array_keys( self::LIST_OPTIONS ) as $slug ) {
+			$this->disable_list( $slug );
+		}
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		if ( null !== $this->sut ) {
+			$endpoint = $this->sut->get_wishlist_endpoint();
+			remove_filter( 'woocommerce_get_query_vars', array( $this->sut, 'add_wishlist_query_var' ) );
+			remove_filter( 'woocommerce_account_menu_items', array( $this->sut, 'add_wishlist_menu_item' ) );
+			remove_filter( 'woocommerce_endpoint_' . $endpoint . '_title', array( $this->sut, 'wishlist_endpoint_title' ) );
+			remove_action( 'woocommerce_account_' . $endpoint . '_endpoint', array( $this->sut, 'render_wishlist_endpoint' ) );
+		}
+
+		foreach ( array_keys( self::LIST_OPTIONS ) as $slug ) {
+			$this->disable_list( $slug );
+		}
+		delete_option( self::FLUSH_QUEUE_OPTION );
+
+		$this->sut = null;
+		parent::tearDown();
+	}
+
+	/**
+	 * Enable the feature backing the given shopper-list slug.
+	 *
+	 * @param string $slug List slug.
+	 */
+	private function enable_list( string $slug ): void {
+		update_option( self::LIST_OPTIONS[ $slug ], 'yes' );
+	}
+
+	/**
+	 * Disable the feature backing the given shopper-list slug.
+	 *
+	 * @param string $slug List slug.
+	 */
+	private function disable_list( string $slug ): void {
+		update_option( self::LIST_OPTIONS[ $slug ], 'no' );
+	}
+
+	/**
+	 * Convenience for the (sfl, wishlist) combinations used by the providers.
+	 *
+	 * @param bool $sfl      Whether to enable cart_save_for_later.
+	 * @param bool $wishlist Whether to enable product_wishlist.
+	 */
+	private function set_features( bool $sfl, bool $wishlist ): void {
+		$sfl ? $this->enable_list( 'saved-for-later' ) : $this->disable_list( 'saved-for-later' );
+		$wishlist ? $this->enable_list( 'wishlist' ) : $this->disable_list( 'wishlist' );
+	}
+
+	/**
+	 * @testdox is_enabled reflects the underlying feature state, with null asking about any list type.
+	 * @dataProvider provide_is_enabled_cases
+	 *
+	 * @param bool        $sfl      Whether SFL is enabled in this case.
+	 * @param bool        $wishlist Whether wishlist is enabled in this case.
+	 * @param string|null $slug     Slug argument to pass to is_enabled().
+	 * @param bool        $expected Expected return value.
+	 */
+	public function test_is_enabled( bool $sfl, bool $wishlist, ?string $slug, bool $expected ): void {
+		$this->set_features( $sfl, $wishlist );
+		$this->assertSame( $expected, $this->sut->is_enabled( $slug ) );
+	}
+
+	/**
+	 * Data provider for {@see test_is_enabled()}.
+	 *
+	 * @return array<string, array{0:bool, 1:bool, 2:?string, 3:bool}>
+	 */
+	public function provide_is_enabled_cases(): array {
+		return array(
+			'no slug: both off'            => array( false, false, null, false ),
+			'no slug: sfl on'              => array( true, false, null, true ),
+			'no slug: wishlist on'         => array( false, true, null, true ),
+			'no slug: both on'             => array( true, true, null, true ),
+			'saved-for-later: on'          => array( true, false, 'saved-for-later', true ),
+			'saved-for-later: off'         => array( false, false, 'saved-for-later', false ),
+			'wishlist: on'                 => array( false, true, 'wishlist', true ),
+			'wishlist: off'                => array( false, false, 'wishlist', false ),
+			'unknown slug never qualifies' => array( true, true, 'unknown-list', false ),
+		);
+	}
+
+	/**
+	 * @testdox get_enabled_slugs returns the slugs of currently enabled list types in declaration order.
+	 * @dataProvider provide_enabled_slug_cases
+	 *
+	 * @param bool               $sfl      Whether SFL is enabled in this case.
+	 * @param bool               $wishlist Whether wishlist is enabled in this case.
+	 * @param array<int, string> $expected Expected slugs.
+	 */
+	public function test_get_enabled_slugs( bool $sfl, bool $wishlist, array $expected ): void {
+		$this->set_features( $sfl, $wishlist );
+		$this->assertSame( $expected, $this->sut->get_enabled_slugs() );
+	}
+
+	/**
+	 * Data provider for {@see test_get_enabled_slugs()}.
+	 *
+	 * @return array<string, array{0:bool, 1:bool, 2:array<int, string>}>
+	 */
+	public function provide_enabled_slug_cases(): array {
+		return array(
+			'none'          => array( false, false, array() ),
+			'sfl only'      => array( true, false, array( 'saved-for-later' ) ),
+			'wishlist only' => array( false, true, array( 'wishlist' ) ),
+			'both'          => array( true, true, array( 'saved-for-later', 'wishlist' ) ),
+		);
+	}
+
+	/**
+	 * @testdox maybe_flush_rewrite_rules queues a flush only for the product_wishlist feature.
+	 * @dataProvider provide_maybe_flush_cases
+	 *
+	 * @param string $feature_id     The feature id passed to the callback.
+	 * @param bool   $expect_queued  Whether the queue option should end up set to 'yes'.
+	 */
+	public function test_maybe_flush_rewrite_rules( string $feature_id, bool $expect_queued ): void {
+		delete_option( self::FLUSH_QUEUE_OPTION );
+		$this->sut->maybe_flush_rewrite_rules( $feature_id );
+		$this->assertSame(
+			$expect_queued ? 'yes' : false,
+			get_option( self::FLUSH_QUEUE_OPTION, false )
+		);
+	}
+
+	/**
+	 * Data provider for {@see test_maybe_flush_rewrite_rules()}.
+	 *
+	 * @return array<string, array{0:string, 1:bool}>
+	 */
+	public function provide_maybe_flush_cases(): array {
+		return array(
+			'wishlist toggles flush'    => array( 'product_wishlist', true ),
+			'sfl change ignored'        => array( 'cart_save_for_later', false ),
+			'unrelated feature ignored' => array( 'agentic_checkout', false ),
+		);
+	}
+
+	/**
+	 * @testdox add_wishlist_query_var injects the wishlist entry and returns an array for non-array input.
+	 * @dataProvider provide_query_var_cases
+	 *
+	 * @param mixed                $input    Filter input.
+	 * @param array<string, mixed> $expected Expected return value.
+	 */
+	public function test_add_wishlist_query_var( $input, array $expected ): void {
+		$this->assertSame( $expected, $this->sut->add_wishlist_query_var( $input ) );
+	}
+
+	/**
+	 * Data provider for {@see test_add_wishlist_query_var()}.
+	 *
+	 * @return array<string, array{0:mixed, 1:array<string, mixed>}>
+	 */
+	public function provide_query_var_cases(): array {
+		return array(
+			'empty'           => array( array(), array( 'wishlist' => 'wishlist' ) ),
+			'with other vars' => array(
+				array( 'orders' => 'orders' ),
+				array(
+					'orders'   => 'orders',
+					'wishlist' => 'wishlist',
+				),
+			),
+			'non-array input' => array( 'not-an-array', array() ),
+		);
+	}
+
+	/**
+	 * @testdox add_wishlist_menu_item inserts the wishlist link before customer-logout when present, or at the end otherwise.
+	 * @dataProvider provide_menu_item_cases
+	 *
+	 * @param mixed              $input         Filter input.
+	 * @param array<int, string> $expected_keys Expected ordered keys.
+	 */
+	public function test_add_wishlist_menu_item( $input, array $expected_keys ): void {
+		$result = $this->sut->add_wishlist_menu_item( $input );
+		$this->assertSame( $expected_keys, array_keys( $result ) );
+	}
+
+	/**
+	 * Data provider for {@see test_add_wishlist_menu_item()}.
+	 *
+	 * @return array<string, array{0:mixed, 1:array<int, string>}>
+	 */
+	public function provide_menu_item_cases(): array {
+		return array(
+			'logout present'  => array(
+				array(
+					'dashboard'       => 'Dashboard',
+					'orders'          => 'Orders',
+					'customer-logout' => 'Log out',
+				),
+				array( 'dashboard', 'orders', 'wishlist', 'customer-logout' ),
+			),
+			'logout absent'   => array(
+				array(
+					'dashboard' => 'Dashboard',
+					'orders'    => 'Orders',
+				),
+				array( 'dashboard', 'orders', 'wishlist' ),
+			),
+			'empty'           => array( array(), array( 'wishlist' ) ),
+			'non-array input' => array( 'not-an-array', array() ),
+		);
+	}
+
+	/**
+	 * @testdox maybe_register_wishlist_endpoint attaches the endpoint hooks only when wishlist is enabled.
+	 * @dataProvider provide_register_endpoint_cases
+	 *
+	 * @param bool $wishlist_on    Whether wishlist is enabled in this case.
+	 * @param bool $hooks_expected Whether all four endpoint hooks should be present.
+	 */
+	public function test_maybe_register_wishlist_endpoint( bool $wishlist_on, bool $hooks_expected ): void {
+		$this->set_features( false, $wishlist_on );
+		$this->sut->maybe_register_wishlist_endpoint();
+
+		$endpoint = $this->sut->get_wishlist_endpoint();
+		$hooks    = array(
+			array( 'woocommerce_get_query_vars', 'add_wishlist_query_var' ),
+			array( 'woocommerce_account_menu_items', 'add_wishlist_menu_item' ),
+			array( 'woocommerce_endpoint_' . $endpoint . '_title', 'wishlist_endpoint_title' ),
+			array( 'woocommerce_account_' . $endpoint . '_endpoint', 'render_wishlist_endpoint' ),
+		);
+		foreach ( $hooks as $hook ) {
+			$this->assertSame(
+				$hooks_expected,
+				(bool) has_filter( $hook[0], array( $this->sut, $hook[1] ) ),
+				"Hook {$hook[0]}::{$hook[1]} state mismatch."
+			);
+		}
+	}
+
+	/**
+	 * Data provider for {@see test_maybe_register_wishlist_endpoint()}.
+	 *
+	 * @return array<string, array{0:bool, 1:bool}>
+	 */
+	public function provide_register_endpoint_cases(): array {
+		return array(
+			'wishlist on attaches hooks'   => array( true, true ),
+			'wishlist off leaves no hooks' => array( false, false ),
+		);
+	}
+}