Shopify Storefront API metafields return null until the metafield definition grants storefront access. Set access.storefront to PUBLIC_READ on the definition through the Admin API, then query with metafield(namespace:, key:) for one value or metafields(identifiers: [...]) for several, and use reference or references to resolve files, products and metaobjects. The query itself never errors; it just hands you null.
That last part is what makes it hard to diagnose. The product is there, the metafield shows a value in the admin, the GraphQL validates, and the field is null. Nothing in the response says why.
Why does the Storefront API return null for a metafield?
Every metafield definition carries three access settings, one per API: admin, storefront and customer account. Shopify’s custom data permissions page lists the storefront default as “Hidden from Storefront API (default)”. Unless whoever created the definition changed that setting, whether in the admin UI, from an app or in a migration script, the metafield is invisible to public storefront tokens. The Storefront API reports an invisible metafield the same way it reports a missing one.
Two older routes no longer exist. The metafieldStorefrontVisibilityCreate mutation was removed in API version 2025-01, as the headless metafields guide notes, and a metafield with no definition at all has no access settings to change. In both cases the fix is the same: a definition with storefront access granted.
The MetafieldAccessInput on the Admin API (2026-10) is where that happens. For a new definition pass it to metafieldDefinitionCreate; for an existing one, metafieldDefinitionUpdate takes the same shape under MetafieldAccessUpdateInput.
mutation ExposeCareInstructions {
metafieldDefinitionUpdate(
definition: {
namespace: "custom"
key: "care_instructions"
ownerType: PRODUCT
access: { storefront: PUBLIC_READ }
}
) {
updatedDefinition { id namespace key }
userErrors { field message }
}
}
MetafieldStorefrontAccess has exactly two values, NONE and PUBLIC_READ. There is no per-token or per-channel grant, so anything you expose is readable by every public Storefront token for the shop. Treat it as public data, because it is.
If you keep definitions in your app’s shopify.app.toml instead, the same switch lives there; our post on shipping metafield definitions in TOML covers that route.
How do you query one metafield or many at once?
Every type that implements the HasMetafields interface gets two fields. In Storefront API 2026-10 that covers Product, ProductVariant, Collection, Customer, Cart, Order, Page, Article, Blog, Market, Location, SellingPlan, Company and CompanyLocation.
metafield(key: String!, namespace: String): Metafield
metafields(identifiers: [HasMetafieldsIdentifier!]!): [Metafield]!
Leave namespace out and Shopify uses the app-reserved namespace, which is only what you want if your app wrote the metafield under $app. For anything created in the admin UI, name the namespace explicitly; custom is the default there.
The plural form takes up to 250 identifiers and returns an array with a nullable element type, so a key with no value, or no access, becomes a null entry rather than a missing one. Match results on namespace and key from the returned objects rather than trusting your eye on array position.
query ProductMetafields($handle: String!) {
product(handle: $handle) {
title
metafields(
identifiers: [
{ namespace: "custom", key: "care_instructions" }
{ namespace: "custom", key: "material" }
{ namespace: "custom", key: "size_guide" }
]
) {
namespace
key
type
value
}
}
}
The Metafield object is deliberately thin: namespace, key, type, value, reference, references, parentResource, createdAt, updatedAt and description. value is always a string. What is inside that string depends on type.
What is actually in the value string?
The type names are documented on Shopify’s list of metafield data types, and a few of them bite if you treat value as plain text.
single_line_text_field and multi_line_text_field are the text you expect. number_integer and number_decimal are numbers serialised as strings. boolean is "true" or "false". date and date_time are ISO 8601.
money is a JSON object, {"amount": "5.99", "currency_code": "CAD"} in the docs’ example. rating is JSON too, carrying value, scale_min and scale_max. weight, dimension and volume are JSON objects with a value and a unit. rich_text_field is a JSON document describing the content tree, not HTML, so it needs a renderer before it reaches a page. json is whatever your app put there.
Reference types, product_reference, variant_reference, collection_reference, file_reference, page_reference, metaobject_reference and mixed_reference, store a gid://shopify/... string in value. Every type also has a list. form, list.product_reference for example, where value is a JSON array of the same strings.
Hydrogen ships a parser so you do not hand-roll this. parseMetafield in @shopify/hydrogen (documented against 2026-04) reads type and fills parsedValue from value, reference or references, so a date comes back as a Date and a reference comes back as the referenced object.
import {parseMetafield, type ParsedMetafields} from '@shopify/hydrogen';
type RawMetafield = {type: string; value: string} | null;
export function CareInstructions({metafield}: {metafield: RawMetafield}) {
if (!metafield) return null;
const parsed = parseMetafield<ParsedMetafields['multi_line_text_field']>(metafield);
return <p className="care">{parsed.parsedValue}</p>;
}
On a non-Hydrogen stack, the same import works from @shopify/hydrogen-react, which is where the utility lives.
How do you resolve references to files, products and metaobjects?
You could take the gid out of value and run a second query. Do not. The Metafield object has reference for single references and references for list types, and both return the MetafieldReference union: Article, Collection, GenericFile, MediaImage, Metaobject, Model3d, Page, Product, ProductVariant or Video. Select the fragments you need and the whole thing resolves in one request.
query ProductWithReferences($handle: String!) {
product(handle: $handle) {
sizeGuide: metafield(namespace: "custom", key: "size_guide") {
reference {
... on MediaImage {
image { url altText width height }
}
... on GenericFile {
url
}
}
}
pairsWith: metafield(namespace: "custom", key: "pairs_with") {
references(first: 4) {
nodes {
... on Product {
handle
title
featuredImage { url altText }
}
}
}
}
fabric: metafield(namespace: "custom", key: "fabric") {
reference {
... on Metaobject {
handle
type
name: field(key: "name") { value }
weight: field(key: "weight_gsm") { value }
}
}
}
}
}
The metaobject case has one extra gate. The Metaobject object and the top-level metaobject(handle:) and metaobjects(type:) queries need the unauthenticated_read_metaobjects scope on your Storefront token, and the metaobject definition needs its own access.storefront set to PUBLIC_READ through MetaobjectAccessInput. A product metafield that points at a hidden metaobject returns the metafield with a null reference, which looks like the symptom from the top of this post but has a different cause. Exposing the metafield is not enough; the thing it points to has to be exposed too. If you are still deciding whether a fabric should be a metaobject at all, our comparison of metaobjects and metafields is the place to start.
Each MetaobjectField carries key, type, value, and its own reference and references, so a metaobject that references an image resolves inline too. Two levels is usually enough. Each level you add is more selections in the query, more cost against the throttle, and another nested union to type on the way out.
What would I actually do?
Audit access before writing a line of storefront code. Pull every definition you intend to read with the Admin API, check access.storefront on each, and update the ones that say NONE. Do the same for metaobject definitions. Put those updates in version control, as TOML in the app or as a script, so a fresh store does not repeat the null hunt.
Query with metafields(identifiers:) for the product page’s fixed set of keys, keep the identifier list in one shared fragment, and resolve references inline with reference and references instead of chasing gids. Run the response through parseMetafield rather than parsing value by hand, and write a renderer for rich_text_field once.
I would not expose a metafield just because it is convenient. PUBLIC_READ means anyone holding the public token, and in a storefront that queries from the browser that token is in the page source. Internal cost prices, supplier notes and anything else merchants type into metafields because the admin lets them should stay at NONE, and the storefront should read a separate, deliberately public definition instead.
Whoooop builds Hydrogen and headless Shopify storefronts and the data models underneath them, including the metafield and metaobject definitions that make content queryable in the first place. If your product pages are waiting on data that keeps coming back null, our Shopify development work usually starts with that audit.