Zero-Token Shopify: A Full Storefront Without API Credentials
Every Shopify store with Online Store enabled exposes public JSON endpoints. No API keys. No Storefront Access Token. No private app. Here is the complete architecture.
Here is something that most Shopify developers do not know. If a store has the Online Store sales channel enabled, it exposes a set of public JSON endpoints that return full product data. No authentication. No API key. No Storefront Access Token. No private app registration. Nothing.
I discovered this while building a headless storefront for a client who did not want to deal with Shopify's app approval process. They had 92 products. They wanted a fast, custom frontend. They did not want to manage API credentials that could expire or get revoked.
So we built the entire storefront on zero tokens. It has been running in production for three months. Here is exactly how it works.
The Public Endpoints
Shopify exposes four JSON endpoints on every store with the Online Store channel active:
GET https://{store}.myshopify.com/products.json
GET https://{store}.myshopify.com/collections.json
GET https://{store}.myshopify.com/products/{handle}.json
GET https://{store}.myshopify.com/collections/{handle}/products.json
These are not hidden. They are not undocumented. Shopify uses them internally for their own Online Store rendering. But almost nobody builds on them directly because the Storefront API is what all the tutorials recommend.
The product endpoint returns everything you need:
// GET /products/premium-leather-bag.json
{
"product": {
"id": 7234567890123,
"title": "Premium Leather Bag",
"handle": "premium-leather-bag",
"body_html": "<p>Hand-stitched Italian leather...</p>",
"vendor": "Great Garage Gear",
"product_type": "Bags",
"tags": ["leather", "handmade", "premium"],
"variants": [
{
"id": 41234567890123,
"title": "Brown / Large",
"price": "289.00",
"compare_at_price": "349.00",
"sku": "PLB-BRN-L",
"available": true,
"inventory_quantity": 12
}
],
"images": [
{
"id": 31234567890123,
"src": "https://cdn.shopify.com/s/files/1/...",
"width": 2048,
"height": 2048,
"alt": "Premium Leather Bag in Brown"
}
],
"options": [
{ "name": "Color", "values": ["Brown", "Black", "Tan"] },
{ "name": "Size", "values": ["Small", "Medium", "Large"] }
]
}
}
Titles. Descriptions. Prices. Compare-at prices. Variant IDs. SKUs. Availability. Full-resolution images with dimensions and alt text. Tags. Product types. Options with all values. This is the same data you would get from the Storefront API, minus metafields.
The Fetch Layer
The fetch implementation is straightforward. We normalize the Shopify response into our own types so the rest of the app never touches Shopify's schema directly:
// lib/shopify.ts
const STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN;
interface ShopifyProduct {
id: number;
title: string;
handle: string;
body_html: string;
vendor: string;
product_type: string;
tags: string[];
variants: ShopifyVariant[];
images: ShopifyImage[];
options: ShopifyOption[];
}
export async function fetchProduct(handle: string): Promise<Product | null> {
const res = await fetch(
`https://${STORE_DOMAIN}/products/${handle}.json`,
{ next: { revalidate: 120 } }
);
if (!res.ok) return null;
const { product } = await res.json();
return normalizeProduct(product);
}
export async function fetchAllProducts(): Promise<Product[]> {
const products: ShopifyProduct[] = [];
let page = 1;
while (true) {
const res = await fetch(
`https://${STORE_DOMAIN}/products.json?limit=250&page=${page}`,
{ next: { revalidate: 120 } }
);
const { products: batch } = await res.json();
if (batch.length === 0) break;
products.push(...batch);
page++;
}
return products.map(normalizeProduct);
}
The next: { revalidate: 120 } is doing the heavy lifting here. This is Next.js ISR (Incremental Static Regeneration). The page is statically generated at build time, then revalidated every 120 seconds. The first visitor after the cache expires triggers a background rebuild. Everyone else gets the cached version.
For 92 products, the full build takes about 8 seconds. The revalidation requests take 200-400ms each. Your visitors never see a loading spinner.
The Pagination Problem
Here is the first real limitation. The /products.json endpoint returns a maximum of 250 products per page. For stores with fewer than 250 products, this is a non-issue. One request gets everything.
For larger catalogs, you paginate with ?page=2&limit=250. But Shopify caps this at around 100 pages in practice. That gives you roughly 25,000 products before this approach breaks down. If your store has more than that, you need the Storefront API. There is no way around it.
But here is the thing: most stores we work with have between 20 and 500 products. The 250-product-per-page limit is not a constraint. It is the entire catalog in one request.
Checkout Without a Cart API
This is where it gets interesting. Without the Storefront API, you do not have access to Shopify's Cart API. No cartCreate mutation. No cartLinesAdd. No cart object.
You do not need one.
Shopify supports direct checkout permalinks. You construct a URL with variant IDs and quantities, and Shopify handles the rest:
// Direct checkout URL construction
function buildCheckoutUrl(
items: CartItem[],
storeDomain: string
): string {
const lineItems = items
.map(item => `${item.variantId}:${item.quantity}`)
.join(',');
return `https://${storeDomain}/cart/${lineItems}`;
}
// Example output:
// https://store.myshopify.com/cart/41234567890123:2,41234567890456:1
This URL drops the customer directly into Shopify's native checkout with those items in their cart. Shopify Checkout handles payment processing, shipping calculation, tax computation, discount codes, everything. You are using the same checkout that Shopify Plus merchants pay $2,000/month for.
The cart state itself lives in the browser. We use a simple React context with localStorage persistence:
// context/CartContext.tsx
interface CartItem {
variantId: number;
quantity: number;
product: Product;
variant: Variant;
}
function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>(() => {
if (typeof window === 'undefined') return [];
const saved = localStorage.getItem('cart');
return saved ? JSON.parse(saved) : [];
});
useEffect(() => {
localStorage.setItem('cart', JSON.stringify(items));
}, [items]);
const addItem = (product: Product, variant: Variant, qty = 1) => {
setItems(prev => {
const existing = prev.find(i => i.variantId === variant.id);
if (existing) {
return prev.map(i =>
i.variantId === variant.id
? { ...i, quantity: i.quantity + qty }
: i
);
}
return [...prev, { variantId: variant.id, quantity: qty, product, variant }];
});
};
const checkout = () => {
const url = buildCheckoutUrl(items, STORE_DOMAIN);
window.location.href = url;
};
return (
<CartContext.Provider value={{ items, addItem, removeItem, checkout }}>
{children}
</CartContext.Provider>
);
}
When the user clicks "Checkout," they leave your site and land on Shopify's checkout. That is the one moment where the experience breaks. Your custom fonts, your colors, your layout. Gone. The customer sees Shopify's default checkout. If you are on Shopify Plus, you can customize this. Everyone else lives with the transition.
For most merchants, this is fine. The checkout conversion rate is the same because Shopify's checkout is extremely well-optimized. It has A/B tested every pixel.
The Complete Architecture
Here is the full stack, top to bottom:
- Framework: Next.js 15 with App Router
- Data source: Shopify public JSON endpoints (zero auth)
- Caching: ISR with 120-second revalidation
- Cart state: React context + localStorage
- Checkout: Shopify permalink redirect
- Hosting: Vercel (free tier handles most stores)
- Images: Shopify CDN (already optimized, already on a CDN)
- Search: Client-side against pre-fetched catalog
The entire data layer is about 200 lines of TypeScript. No GraphQL client. No SDK. No dependency on @shopify/hydrogen or @shopify/storefront-api-client. The fetch calls are plain fetch().
What You Cannot Do
Honesty matters more than hype. Here is what zero-token Shopify cannot do:
- Customer accounts. No login, no order history, no saved addresses. The public endpoints expose product data only.
- Order management. You cannot look up orders, process returns, or show tracking information.
- Metafields. The public JSON endpoints do not include metafields. If you store sizing charts, care instructions, or custom data in metafields, you cannot access them.
- Inventory webhooks. You will not get real-time inventory updates. The 120-second ISR cache means a product could show as available for up to two minutes after it sells out.
- Discount codes at product level. You can still apply discount codes at checkout, but you cannot show discounted prices on your storefront in real time.
- Cart-level features. No automatic discounts based on cart contents. No "buy 2 get 1 free" logic on your frontend. Those still work at Shopify checkout, but your UI cannot reflect them.
For a catalog of 20-500 products where the primary goal is beautiful product presentation and fast page loads, none of these limitations matter. The merchant manages orders in Shopify admin. Customers check out on Shopify. The headless frontend is the showroom, not the back office.
Performance Numbers
Here are real Lighthouse scores from the production storefront we built on this architecture:
- Performance: 98
- Accessibility: 100
- Best Practices: 100
- SEO: 100
- First Contentful Paint: 0.4s
- Largest Contentful Paint: 0.8s
- Total Blocking Time: 0ms
- Cumulative Layout Shift: 0
Compare that to the average Shopify theme, which scores 40-60 on Performance. Shopify themes load the entire Shopify JavaScript framework, analytics scripts, app scripts, and theme JavaScript. That is typically 800KB-1.2MB of JavaScript on first load. Our headless storefront ships about 180KB total.
The static pages load in under 500ms on a 4G connection. Product pages with multiple high-resolution images load in under a second. There is no spinner. There is no skeleton screen. The content is there when the browser paints.
The Build Process
At build time, Next.js calls fetchAllProducts() once. It gets the full catalog. It generates static pages for every product and every collection. The Shopify store gets maybe 10 requests total during a build. At runtime, each page revalidates independently every 120 seconds. A store with 92 products generates about 46 revalidation requests per minute in the worst case. Shopify does not rate-limit these public endpoints at that volume.
// app/products/[handle]/page.tsx
export async function generateStaticParams() {
const products = await fetchAllProducts();
return products.map(p => ({ handle: p.handle }));
}
export async function generateMetadata({ params }): Promise<Metadata> {
const product = await fetchProduct(params.handle);
if (!product) return {};
return {
title: `${product.title} | Store Name`,
description: product.description.slice(0, 160),
openGraph: {
images: [product.images[0]?.src],
},
};
}
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.handle);
if (!product) notFound();
return <ProductDetail product={product} />;
}
Every product page has proper meta tags, Open Graph images, and structured data generated from the Shopify product data. This is SEO that most Shopify themes get wrong because they rely on Shopify's Liquid templating, which does not give you fine-grained control over meta tags.
When to Use This
Zero-token Shopify is the right architecture when:
- Your catalog has fewer than 250 products (single request, no pagination)
- You do not need customer accounts on the frontend
- You want complete design control
- Performance is a competitive advantage (high-ticket products, brand-driven stores)
- You do not want to manage API credentials or worry about token rotation
It is the wrong architecture when you need real-time inventory, customer login, cart-level discounts on the frontend, or a catalog with more than a few thousand products.
For the stores we work with, the sweet spot is clear. High-ticket products. Brand-forward design. Fewer than 200 SKUs. The kind of store where a $50K agency builds you a custom Shopify theme and you still get a 45 Lighthouse score because the Shopify platform JavaScript is unavoidable.
With zero-token headless, you get a 98 Lighthouse score, complete design freedom, and zero ongoing API credential management. The trade-off is a checkout redirect and no metafield access. For most merchants, that trade-off is obvious.
We are building a complete system on this architecture. More on that in the coming months.