Headless Commerce May 20, 2025 11 min read

Shopify's Public JSON API: The Headless Shortcut Nobody Uses

Every Shopify store already has a public API. No keys. No OAuth. No Partner account. Just append .json to any URL and start building.

Tyler Colby · Founder, Colby's Data Movers

The Best-Kept Non-Secret in Shopify

Go to any Shopify store. Any one. Add /products.json to the end of the domain. Hit enter.

You just accessed a full JSON representation of every product in that store. Titles, descriptions, prices, images, variants, tags, vendor names, inventory status. All of it. No authentication. No API key. No rate limiting (within reason). No Shopify Partner account.

This has been available since Shopify launched. It is not hidden. It is not deprecated. It is not undocumented (though the documentation is sparse). It is just ignored. Every headless Shopify tutorial starts with "First, create a Storefront API access token" or "Set up a Hydrogen project." Nobody mentions that you can skip all of that.

The Endpoints

Here are all the public JSON endpoints that every Shopify store exposes by default:

# All products (paginated, 30 per page default)
https://your-store.myshopify.com/products.json

# Single product by handle
https://your-store.myshopify.com/products/product-handle.json

# All collections
https://your-store.myshopify.com/collections.json

# Products in a specific collection
https://your-store.myshopify.com/collections/collection-handle/products.json

# Single collection
https://your-store.myshopify.com/collections/collection-handle.json

# Pages (About, Contact, etc.)
https://your-store.myshopify.com/pages/page-handle.json

# Blog articles
https://your-store.myshopify.com/blogs/blog-handle/articles.json

# Single article
https://your-store.myshopify.com/blogs/blog-handle/articles/article-handle.json

That is the full set. Products, collections, pages, and articles. If you are building a product catalog frontend, this covers 90% of what you need.

The Data Shape

Here is what you actually get back from /products.json. I am trimming for readability but the structure is accurate:

{
  "products": [
    {
      "id": 8234567890123,
      "title": "Heavy-Duty Winch Mount",
      "handle": "heavy-duty-winch-mount",
      "body_html": "<p>Fits all standard winch plates...</p>",
      "published_at": "2025-03-14T10:00:00-04:00",
      "created_at": "2025-03-10T08:30:00-04:00",
      "updated_at": "2025-05-18T14:22:00-04:00",
      "vendor": "TrailReady",
      "product_type": "Winch Accessories",
      "tags": ["winch", "mounting", "steel", "heavy-duty"],
      "variants": [
        {
          "id": 44567890123456,
          "title": "Black / Standard",
          "price": "289.99",
          "compare_at_price": "349.99",
          "sku": "WM-HD-BLK-STD",
          "available": true,
          "inventory_quantity": null,
          "weight": 18.5,
          "weight_unit": "lb",
          "option1": "Black",
          "option2": "Standard",
          "option3": null
        }
      ],
      "images": [
        {
          "id": 39876543210987,
          "src": "https://cdn.shopify.com/s/files/...",
          "width": 2048,
          "height": 2048,
          "alt": "Heavy-duty winch mount installed"
        }
      ],
      "options": [
        {"name": "Color", "values": ["Black", "Raw Steel"]},
        {"name": "Size", "values": ["Standard", "Extended"]}
      ]
    }
  ]
}

A few things to notice. Prices are strings, not numbers. Parse them. The body_html field contains raw HTML from the Shopify rich text editor. Images include dimensions, which is useful for responsive image rendering. The available field on variants tells you if something is in stock, but inventory_quantity is usually null (Shopify hides exact counts from the public API for obvious reasons).

Pagination

The default page size is 30 products. You can increase it to 250 with a query parameter:

/products.json?limit=250

Pagination uses the page parameter:

/products.json?limit=250&page=1
/products.json?limit=250&page=2
/products.json?limit=250&page=3

Here is the catch: page-based pagination tops out. Shopify will not return results beyond a certain depth. For stores with fewer than 2,500 products, this is not a problem. For larger catalogs, you will need to paginate by collection or use the Storefront API for the long tail.

In practice, most of the high-ticket stores I work with have 50-500 products. The public JSON API handles this without any pagination complexity.

Fetching Products in JavaScript

Here is a complete product fetcher. No dependencies. No SDK. Just fetch.

const STORE_URL = 'https://your-store.myshopify.com';

async function getAllProducts() {
  const products = [];
  let page = 1;

  while (true) {
    const res = await fetch(
      `${STORE_URL}/products.json?limit=250&page=${page}`
    );

    if (!res.ok) {
      throw new Error(`Shopify returned ${res.status}`);
    }

    const data = await res.json();

    if (data.products.length === 0) break;

    products.push(...data.products);
    page++;

    // Be polite. Shopify does not publish rate limits
    // for public JSON but hammering it is rude.
    await new Promise(r => setTimeout(r, 500));
  }

  return products;
}

async function getProduct(handle) {
  const res = await fetch(
    `${STORE_URL}/products/${handle}.json`
  );

  if (!res.ok) return null;

  const data = await res.json();
  return data.product;
}

async function getCollection(handle) {
  const res = await fetch(
    `${STORE_URL}/collections/${handle}/products.json?limit=250`
  );

  if (!res.ok) return [];

  const data = await res.json();
  return data.products;
}

That is your entire Shopify data layer. Three functions. No API tokens. No OAuth flows. No environment variables to manage. No secret rotation. No Partner dashboard.

Using This for Static Site Generation

The real power shows up at build time. In a Next.js project, you fetch all products during getStaticProps and generate every product page as static HTML:

// lib/shopify.js
const STORE = 'https://your-store.myshopify.com';

export async function getAllProducts() {
  const products = [];
  let page = 1;
  while (true) {
    const res = await fetch(
      `${STORE}/products.json?limit=250&page=${page}`
    );
    const data = await res.json();
    if (!data.products.length) break;
    products.push(...data.products);
    page++;
  }
  return products;
}

export async function getProductByHandle(handle) {
  const res = await fetch(
    `${STORE}/products/${handle}.json`
  );
  const data = await res.json();
  return data.product;
}

// pages/products/[handle].js
import { getAllProducts, getProductByHandle } from '../../lib/shopify';

export async function getStaticPaths() {
  const products = await getAllProducts();
  return {
    paths: products.map(p => ({
      params: { handle: p.handle }
    })),
    fallback: 'blocking'
  };
}

export async function getStaticProps({ params }) {
  const product = await getProductByHandle(params.handle);
  if (!product) return { notFound: true };

  return {
    props: { product },
    revalidate: 300 // Rebuild every 5 minutes
  };
}

At build time, Next.js calls getStaticPaths, fetches all product handles, and generates a static HTML file for each product. After deploy, the pages are served from a CDN. No server. No Shopify latency. Time to first byte is typically 20-50ms.

The revalidate: 300 means Next.js will regenerate the page in the background every 5 minutes. If a price changes in Shopify, the static page updates within 5 minutes without a full redeploy. This is Incremental Static Regeneration (ISR) and it is the reason Next.js wins for headless commerce.

What About Cart and Checkout?

The public JSON API is read-only. You cannot create carts, process orders, or handle checkout. That is fine. You do not need to.

For cart management, use Shopify's Buy SDK on the client side:

import Client from 'shopify-buy';

const client = Client.buildClient({
  domain: 'your-store.myshopify.com',
  storefrontAccessToken: 'your-storefront-token'
});

// Create a checkout
const checkout = await client.checkout.create();

// Add item to checkout
await client.checkout.addLineItems(checkout.id, [{
  variantId: 'gid://shopify/ProductVariant/44567890123456',
  quantity: 1
}]);

// Redirect to Shopify checkout
window.location.href = checkout.webUrl;

The Buy SDK does need a Storefront Access Token. But this is a public token (it ships to the browser). You create it once in your Shopify admin under Apps > Develop apps. It takes 60 seconds.

There is an even simpler approach. Shopify checkout permalinks:

// Direct checkout URL. No SDK needed.
// Format: /cart/VARIANT_ID:QUANTITY
const checkoutUrl =
  'https://your-store.myshopify.com/cart/' +
  '44567890123456:1'; // variant_id:quantity

// Multiple items
const multiUrl =
  'https://your-store.myshopify.com/cart/' +
  '44567890123456:1,44567890123789:2';

That URL takes the buyer directly to Shopify checkout with the items pre-loaded. No cart API. No SDK. No token. Just a URL.

For a simple store with straightforward products, this is all you need. Build your frontend with the public JSON API. Link the buy buttons to checkout permalinks. Zero API tokens in your entire stack.

The Limitations (Be Honest About Them)

The public JSON API is not the Storefront API. It has real limitations:

  • 250 product cap per page. You can paginate, but deep pagination is unreliable. Stores with 2,500+ products should use the Storefront API for full catalog access.
  • No filtering or sorting. You get all products in default order. Filtering by price, tag, or type must happen on your end. At build time, this is fine. For runtime filtering on large catalogs, it is slow.
  • No inventory counts. You get available: true/false but not "3 left in stock." If you need exact counts, you need the Admin API.
  • No customer data. No order history, no customer accounts, no wishlists. This is strictly product catalog data.
  • No metafields. Shopify metafields (custom product data) are not included in the public JSON response. If your products rely on metafields for specs, certifications, or custom data, you need the Storefront API.
  • No webhook support. You cannot subscribe to product updates. You poll on a schedule or rebuild on deploy.
  • No guaranteed SLA. This is a public endpoint, not a contracted API. Shopify could change or restrict it. In practice, it has been stable for years, but you have no recourse if it changes.

When to Use This vs. the Storefront API

Use the public JSON API when:

  • You have fewer than 2,500 products
  • You do not need metafields
  • You are building at build time (SSG), not runtime
  • You want the simplest possible setup
  • You are building a proof of concept or MVP

Use the Storefront API when:

  • You need metafields
  • You have a large catalog (2,500+ products)
  • You need real-time inventory data
  • You need customer account features
  • You are building a production app that needs guaranteed uptime

In practice, many of our builds start with the public JSON API and never need to upgrade. A 200-product store with ISR and checkout permalinks does not need GraphQL and access tokens. The complexity is not worth it.

A Complete Minimal Example

Here is a full product page component. No Shopify SDK. No GraphQL. Just the public JSON data rendered with React:

// components/ProductPage.jsx
import { useState } from 'react';

export default function ProductPage({ product }) {
  const [selectedVariant, setSelectedVariant] = useState(
    product.variants[0]
  );

  const checkoutUrl =
    `https://your-store.myshopify.com/cart/` +
    `${selectedVariant.id}:1`;

  return (
    <div className="product">
      <div className="product-images">
        {product.images.map(img => (
          <img
            key={img.id}
            src={img.src}
            alt={img.alt || product.title}
            width={img.width}
            height={img.height}
            loading="lazy"
          />
        ))}
      </div>

      <div className="product-info">
        <h1>{product.title}</h1>
        <p className="price">${selectedVariant.price}</p>

        {product.variants.length > 1 && (
          <select
            value={selectedVariant.id}
            onChange={e => {
              const v = product.variants.find(
                v => v.id === Number(e.target.value)
              );
              setSelectedVariant(v);
            }}
          >
            {product.variants.map(v => (
              <option key={v.id} value={v.id}>
                {v.title} - ${v.price}
              </option>
            ))}
          </select>
        )}

        <a
          href={checkoutUrl}
          className="buy-button"
        >
          {selectedVariant.available
            ? 'Buy Now'
            : 'Out of Stock'}
        </a>

        <div
          className="description"
          dangerouslySetInnerHTML={{
            __html: product.body_html
          }}
        />
      </div>
    </div>
  );
}

That is a complete, functional product page. It renders images, handles variant selection, and links directly to Shopify checkout. The entire Shopify integration is one fetch call at build time and one URL at checkout.

Why This Matters

The Shopify ecosystem has a complexity problem. Every tutorial, every course, every agency proposal starts with the Storefront API, Hydrogen, or a third-party headless platform. They add layers of abstraction on top of a system that already exposes its data publicly.

For most stores, especially the high-ticket stores we work with, the public JSON API is enough. It is simpler. It is faster to set up. It has fewer moving parts that can break. And when it is not enough, you can add the Storefront API for the specific features you need without ripping out your entire data layer.

Start simple. Add complexity only when you have a specific reason. The public JSON API is the simplest starting point for headless Shopify, and nobody talks about it.

If you want to see this in action, reach out. We build headless Shopify storefronts for high-ticket brands. The first step is always the same: fetch the JSON, see the data, and realize how much simpler this can be.