The Shopify resource picker is the admin’s own product, variant and collection chooser, opened from an embedded app with await shopify.resourcePicker({ type: 'product' }). It resolves to an array of the chosen resources, or undefined if the merchant cancels. Save the GIDs it returns, pass them back as selectionIds next time, and reload titles from the Admin API.
That is the whole pattern. Most of the detail is about the second visit, when a merchant comes back to your “choose featured products” screen and expects to see what they picked last time, still accurate. The full React Router route is near the end.
Everything here is from the App Home Resource Picker API reference and the @shopify/app-bridge-types 0.7.4 definitions, checked in November 2026. The Admin GraphQL in the example was validated against API version 2026-10, the latest stable release on Shopify’s versioning page as of 14 November 2026.
Which resource picker options matter?
There are six, and only type is required.
type is 'product', 'variant' or 'collection'. It decides what the merchant sees and the shape of what comes back.
multiple defaults to false. Pass true for no limit, or a number to cap the selection. One catch is written into the docs: with type: 'product', a merchant can still tick several variants of one product while multiple is false. If you mean “one product, no variants”, add filter: { variants: false }.
action is 'add' (the default) or 'select', and only changes the verb in the picker’s title. Use 'select' when the choice replaces what was there before, which is most settings screens.
selectionIds preselects resources. Each entry is { id }, optionally with a variants array of { id }, all in GID form (gid://shopify/Product/123). The merchant sees the current selection and can remove items from it before confirming.
filter restricts what is listed. It takes five fields: hidden, variants, draft, archived and query. The three product-status flags behave differently when left out: an undefined draft or archived does not hide anything, it shows those products with a badge. Set them to false if a draft product in your list would be a bug. hidden: false drops products that are not published on any sales channel.
query exists twice, which trips people up. The top-level query is pre-filled into the search box, where the merchant can see and edit it. filter.query runs server-side and stays hidden. Both use Shopify’s search syntax, so filter: { query: 'vendor:Acme' } or 'published_status:published' works, with all the quirks covered in our guide to Admin API search syntax. If the restriction is a business rule, use filter.query, because anything in the visible search box can be deleted by the merchant.
Nothing else filters. The reference lists those five fields as the limit, and the picker only knows products, variants and collections. For customers, orders or your app’s own records there is a separate Picker API.
Why does the picker return undefined?
Because the merchant closed it. A confirmed selection is an array; a cancel is undefined. An empty array also happens: the merchant opened a preselected picker, removed everything, and confirmed.
Those are two different instructions. Cancel means “leave it as it was”. An empty confirm means “clear it”. Code that does const items = (await shopify.resourcePicker(opts)) ?? [] folds them together and wipes a merchant’s saved list every time they close the picker to look something up. Check for undefined first and return early.
If your code predates App Bridge 4, there is a second trap. Older code read the result as { selection }. The current types still put a selection property on the returned array, marked @private and @deprecated, so const { selection } = await shopify.resourcePicker(...) keeps compiling and keeps working. Change it to use the array directly before that property goes.
What is in the payload, and what should you save?
A lot. A product comes back with id, title, handle, status, vendor, tags, images, options, totalInventory and a variants array, among other fields. A variant brings price, sku, inventoryQuantity and its parent product. A collection brings ruleSet, productsCount and sortOrder.
It is tempting to store that object. Don’t. It is a snapshot taken in the browser at the moment the merchant clicked, and your server should not trust a copy of catalogue data that arrived from the client. Titles change, products get archived, prices move. Store the IDs, then read anything you display from the Admin API on the server, where the request is authenticated and the data is current.
The picker is also permission-aware: it only lists resources that both your app and the current staff member can access. The loader query below needs the read_products scope.
A complete featured-products route
This is a route for an app built on Shopify’s React Router template (@shopify/app-bridge-react v4 with useAppBridge, authenticate.admin from shopify.server). The merchant picks up to ten products. We save their GIDs to an app-data metafield on the AppInstallation, typed list.product_reference, so Shopify itself checks that every value is a real product. The loader reads the references back with live titles and status and turns them into selectionIds for the next visit.
// app/routes/app.featured.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { useAppBridge } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
const MAX_PRODUCTS = 10;
const NAMESPACE = "app_config";
const KEY = "featured_products";
type FeaturedProduct = { id: string; title: string; status: string };
function parseIds(raw: FormDataEntryValue | null): string[] | null {
try {
const ids: unknown = JSON.parse(String(raw ?? ""));
if (!Array.isArray(ids) || ids.length > MAX_PRODUCTS) return null;
const ok = ids.every(
(id) => typeof id === "string" && id.startsWith("gid://shopify/Product/"),
);
return ok ? ids : null;
} catch {
return null;
}
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(
`#graphql
query FeaturedProducts($namespace: String!, $key: String!) {
currentAppInstallation {
metafield(namespace: $namespace, key: $key) {
references(first: 10) {
nodes {
... on Product { id title status }
}
}
}
}
}`,
{ variables: { namespace: NAMESPACE, key: KEY } },
);
const { data } = await response.json();
const nodes: FeaturedProduct[] =
data?.currentAppInstallation?.metafield?.references?.nodes ?? [];
return { products: nodes };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { admin } = await authenticate.admin(request);
const form = await request.formData();
const ids = parseIds(form.get("productIds"));
if (ids === null) return { error: "Invalid selection" };
const installation = await admin.graphql(
`#graphql
query AppInstallationId { currentAppInstallation { id } }`,
);
const ownerId: string = (await installation.json()).data.currentAppInstallation.id;
if (ids.length === 0) {
await admin.graphql(
`#graphql
mutation ClearFeatured($metafields: [MetafieldIdentifierInput!]!) {
metafieldsDelete(metafields: $metafields) {
deletedMetafields { key }
userErrors { field message }
}
}`,
{ variables: { metafields: [{ ownerId, namespace: NAMESPACE, key: KEY }] } },
);
return { ok: true };
}
const response = await admin.graphql(
`#graphql
mutation SaveFeatured($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id }
userErrors { field message code }
}
}`,
{
variables: {
metafields: [
{
ownerId,
namespace: NAMESPACE,
key: KEY,
type: "list.product_reference",
value: JSON.stringify(ids),
},
],
},
},
);
const { data } = await response.json();
const errors = data?.metafieldsSet?.userErrors ?? [];
return errors.length > 0 ? { error: errors[0].message } : { ok: true };
};
export default function FeaturedProducts() {
const { products } = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
const shopify = useAppBridge();
async function choose() {
const selected = await shopify.resourcePicker({
type: "product",
action: "select",
multiple: MAX_PRODUCTS,
selectionIds: products.map((product) => ({ id: product.id })),
filter: { variants: false, draft: false, archived: false },
});
if (selected === undefined) return; // cancelled: keep what is saved
fetcher.submit(
{ productIds: JSON.stringify(selected.map((product) => product.id)) },
{ method: "post" },
);
}
return (
<s-page heading="Featured products">
<s-section heading="Shown on the storefront">
{products.length === 0 ? (
<s-paragraph>No products chosen yet.</s-paragraph>
) : (
<s-unordered-list>
{products.map((product) => (
<s-list-item key={product.id}>
{product.title}
{product.status !== "ACTIVE" && (
<s-badge tone="warning">{product.status}</s-badge>
)}
</s-list-item>
))}
</s-unordered-list>
)}
<s-button variant="primary" onClick={choose}>
Choose products
</s-button>
{fetcher.data && "error" in fetcher.data && (
<s-paragraph>{fetcher.data.error}</s-paragraph>
)}
</s-section>
</s-page>
);
}
A few choices in there are deliberate.
The action validates the IDs again even though the picker produced them. The form post is an ordinary request to your server, and the browser is not where you enforce rules. The gid://shopify/Product/ prefix check and the ten-item cap are cheap, and list.product_reference gives Shopify a chance to reject anything that is not a product.
An empty confirm deletes the metafield rather than writing an empty list. The metafieldsDelete reference says a delete of a metafield that does not exist still succeeds, so a merchant clearing an already-empty list is not an error.
The owner is the app installation, found with currentAppInstallation { id }, the approach Shopify’s metafields guide uses for app-data metafields. That keeps the data with the install and out of the merchant’s way. Our metafieldsSet guide covers the limits and the compare-and-set option if two staff might edit at once.
The loader’s status badge exists because draft: false only filters what the picker shows. A product picked while active and set to draft next week stays in the metafield. Reading status fresh is how you find out.
The JSX uses Polaris web components (s-page, s-section, s-button), which need no import in an App Home page. If your app still uses Polaris React, our post on moving from Polaris React to web components covers doing it a route at a time.
Do you need the picker at all?
Use it whenever a merchant chooses products, variants or collections inside your app. It brings the admin’s own search, the merchant already knows it, and you write no catalogue UI.
Skip it in two cases. If the choice is a rule (“everything tagged sale”), store the rule and resolve it at read time; a picked list goes stale the day a new sale product is added. And if the merchant is picking from more than a few hundred items, a picker is the wrong shape. A CSV import or a bulk operation will be faster for them and for you.
We build embedded apps and admin tooling like this for merchants and agencies, usually alongside the theme or storefront work it feeds. Our Shopify development page has more on how we work.