ADSX
JULY 1, 2026 // UPDATED JUL 1, 2026

Build a Headless Shopify Catalog Browser in Next.js

Build a browsable headless catalog on the Shopify Storefront API: fetch products and collections, render a grid, add search, and wire a variant picker.

AUTHOR
AE
AdsX Engineering
SHOPIFY API & COMMERCE ENGINEERING
READ TIME
8 MIN
SUMMARY

Build a browsable headless catalog on the Shopify Storefront API: fetch products and collections, render a grid, add search, and wire a variant picker.

To build a headless Shopify catalog browser you install a custom app to get a Storefront API access token, query the products and collections connections over GraphQL, and render the results in a small Next.js front end — a product grid, a search box, and a product page with a variant picker. This tutorial builds all three, step by step, with runnable code against Storefront API version 2026-01. The result is a browsable catalog you can deploy anywhere.

This is the end-to-end build that the Storefront API catalog-read recipe points to as its implementation — that piece is the reference; this is the working app. For the full API surface, see the Shopify Storefront API guide, and for the wider catalog picture, the Shopify Product Catalog API guide.

Step 1: Create a Storefront API access token

In your store admin, go to Settings → Apps and sales channels → Develop apps, create an app, then under Configuration enable Storefront API access and grant the unauthenticated_read_product_listings scope. Install the app and copy the public Storefront access token from the API credentials tab. Unlike the Admin API token, this one is read-only and safe to ship to the browser (Shopify: Storefront API authentication).

# .env.local
NEXT_PUBLIC_SHOPIFY_DOMAIN=your-store.myshopify.com
NEXT_PUBLIC_STOREFRONT_TOKEN=your-public-storefront-token

The distinction matters: the Admin API token can read and write orders, customers, and inventory, so it must never touch client code. The public Storefront token can only read the storefront data you enabled, which is why a catalog browser is one of the few places it is fine to expose a Shopify credential in the browser at all. If you would rather keep even the read token server-side, everything below still works — just call the fetch helper from Server Components and route handlers only, which is the default in this build.

Step 2: Write a tiny Storefront fetch helper

Every request is a POST to the versioned GraphQL endpoint with the X-Shopify-Storefront-Access-Token header. Wrap it once so the rest of the app just passes a query and variables.

// lib/storefront.js
const endpoint = `https://${process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN}/api/2026-01/graphql.json`;

export async function storefront(query, variables = {}) {
  const res = await fetch(endpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Shopify-Storefront-Access-Token": process.env.NEXT_PUBLIC_STOREFRONT_TOKEN,
    },
    body: JSON.stringify({ query, variables }),
    next: { revalidate: 60 }, // cache the catalog for 60s
  });
  const { data, errors } = await res.json();
  if (errors) throw new Error(errors.map((e) => e.message).join("; "));
  return data;
}

The Storefront API is unauthenticated in the OAuth sense — the public token is the only credential, and it grants exactly the read scopes you enabled, nothing more (Shopify: Storefront API).

Step 3: Query products and collections

The products connection returns a paginated list; each node exposes title, handle, a priceRange, featuredImage, and a variants connection. Request only what the grid renders — the Storefront API has a cost budget just like the Admin API.

query CatalogGrid($first: Int!, $cursor: String) {
  products(first: $first, after: $cursor, sortKey: BEST_SELLING) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      handle
      title
      featuredImage { url altText }
      priceRange {
        minVariantPrice { amount currencyCode }
      }
    }
  }
  collections(first: 20) {
    nodes { id handle title }
  }
}

Here are the Storefront fields this build relies on and what each one drives in the UI:

Storefront fieldType / connectionUsed for
products.nodesProduct connectionThe grid list
product.handleStringThe /product/[handle] route
priceRange.minVariantPriceMoneyV2"From $X" price on cards
featuredImageImageGrid thumbnail
collections.nodesCollection connectionCategory filter buttons
variants.selectedOptions[SelectedOption!]The variant picker
variant.availableForSaleBooleanEnable/disable "Add"

Note the sortKey: BEST_SELLING argument — the Storefront API sorts products server-side by a fixed set of keys (BEST_SELLING, PRICE, CREATED_AT, TITLE, and RELEVANCE when a query is present), so you never sort client-side. The pageInfo { hasNextPage endCursor } block is what powers "Load more": pass endCursor back as the cursor variable on the next request until hasNextPage is false, exactly the cursor pattern the Storefront API shares with the Admin API (Shopify: pagination).

Step 4: Render the product grid

Fetch on the server in a Server Component so the token stays off the client and the first paint is fully populated. Map each node to a card that links to its product page by handle.

// app/page.jsx
import { storefront } from "@/lib/storefront";
import { CATALOG_GRID } from "@/lib/queries";
import Link from "next/link";

export default async function CatalogPage() {
  const data = await storefront(CATALOG_GRID, { first: 24 });
  const { products, collections } = data;

  return (
    <main>
      <nav>
        {collections.nodes.map((c) => (
          <Link key={c.id} href={`/collection/${c.handle}`}>{c.title}</Link>
        ))}
      </nav>
      <ul className="grid">
        {products.nodes.map((p) => (
          <li key={p.id}>
            <Link href={`/product/${p.handle}`}>
              <img src={p.featuredImage?.url} alt={p.featuredImage?.altText ?? p.title} />
              <h3>{p.title}</h3>
              <span>
                From {p.priceRange.minVariantPrice.amount}{" "}
                {p.priceRange.minVariantPrice.currencyCode}
              </span>
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

The Storefront products connection accepts a query argument using Shopify's search syntaxtitle:*shoe*, product_type:Shirt, tag:sale. Wire a client component that debounces input and calls a route handler so the token stays server-side.

query SearchProducts($query: String!) {
  products(first: 20, query: $query) {
    nodes { id handle title featuredImage { url altText } }
  }
}
// app/search/SearchBox.jsx
"use client";
import { useState } from "react";

export default function SearchBox() {
  const [results, setResults] = useState([]);

  async function onChange(e) {
    const term = e.target.value.trim();
    if (!term) return setResults([]);
    const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`);
    setResults((await res.json()).products.nodes);
  }

  return (
    <div>
      <input onChange={onChange} placeholder="Search the catalog…" />
      <ul>{results.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
    </div>
  );
}

The route handler (app/api/search/route.js) calls storefront(SEARCH_PRODUCTS, { query: \title:${q}` })` and returns the JSON — the public token never reaches the browser this way, and you can rate-limit the endpoint.

Step 6: Build the product page with a variant picker

Fetch a single product by handle with productByHandle, pulling each variant's selectedOptions so you can render one control per option and match the user's choice back to a variant.

query ProductByHandle($handle: String!) {
  productByHandle(handle: $handle) {
    id
    title
    descriptionHtml
    options { name values }
    variants(first: 100) {
      nodes {
        id
        availableForSale
        price { amount currencyCode }
        selectedOptions { name value }
      }
    }
  }
}
// app/product/[handle]/VariantPicker.jsx
"use client";
import { useState } from "react";

export default function VariantPicker({ options, variants }) {
  const [selected, setSelected] = useState(
    Object.fromEntries(options.map((o) => [o.name, o.values[0]]))
  );

  const match = variants.find((v) =>
    v.selectedOptions.every((o) => selected[o.name] === o.value)
  );

  return (
    <div>
      {options.map((opt) => (
        <fieldset key={opt.name}>
          <legend>{opt.name}</legend>
          {opt.values.map((val) => (
            <button
              key={val}
              aria-pressed={selected[opt.name] === val}
              onClick={() => setSelected((s) => ({ ...s, [opt.name]: val }))}
            >
              {val}
            </button>
          ))}
        </fieldset>
      ))}
      <p>
        {match?.price.amount} {match?.price.currencyCode}
      </p>
      <button disabled={!match?.availableForSale}>
        {match?.availableForSale ? "Add to cart" : "Sold out"}
      </button>
    </div>
  );
}

The match line is the whole trick: a variant is uniquely identified by the set of its selectedOptions, so you find it by checking that every selected option value equals the user's choice. That matched id is what you later hand to cartLinesAdd to start a checkout.

Two refinements make this production-grade. First, disable option values that would produce a sold-out or nonexistent combination by pre-computing which selectedOptions sets actually resolve to an availableForSale variant — this stops shoppers from selecting "Red / XXL" when it does not exist. Second, cap variants(first: 100): a product with more than 100 variants needs nested pagination, and in practice it is a sign the store is encoding data in variants that belongs in metafields. For most catalogs a single page of variants covers every product.

Step 7: The working result and deploying it

You now have a browsable catalog: a server-rendered grid, collection filters, search-as-you-type, and product pages with a live variant picker and price. Because the Storefront token is public and read-only, deployment is trivial — push to Vercel, Netlify, or any Node host, set the two environment variables, and it runs. Keep the revalidate: 60 cache so the catalog stays fresh without hammering the API, and move the search route behind a rate limit before going public.

If your headless storefront is live but not converting, the bottleneck usually is not the code — it is catalog data quality and the ad campaigns pointing at it. That is the work AdsX does for Shopify brands: turning a clean catalog into paid-ads and AI-shopping performance.

Next steps

ABOUT THE AUTHOR
AE
AdsX Engineering
SHOPIFY API & COMMERCE ENGINEERING

The AdsX engineering team builds the data pipelines that turn a Shopify product catalog into high-performing ad feeds across Google, Meta, and AI shopping agents. We work hands-on with the Shopify Admin GraphQL API, the Product Feed and Catalog APIs, metafields, and bulk operations every day, and these guides document the patterns we use in production.

MORE BY ADSX ENGINEERING

Ready to Dominate AI Search?

Get your free AI visibility audit and see how your brand appears across ChatGPT, Claude, and more.

Get Your Free Audit