Jamstack Storefront
Overview
A Jamstack storefront pre-renders catalog pages at build time for maximum performance and CDN cacheability, while using client-side JavaScript and commerce APIs for dynamic functionality (cart, checkout, account). Next.js with Incremental Static Regeneration (ISR) and Astro with on-demand rendering are the two dominant approaches, each offering different tradeoffs between build times, freshness, and interactivity. This skill covers setting up a Jamstack commerce site, managing catalog regeneration, and integrating headless commerce APIs.
When to Use This Skill
- When SEO and Core Web Vitals scores are top priorities — static HTML scores near-perfect Lighthouse results
- When you have a large catalog that rarely changes and want sub-100ms page loads from CDN
- When you want to decouple the commerce backend (Shopify, Saleor, commercetools) from the storefront deployment cycle
- When your team wants to use modern React/Astro tooling rather than a platform's proprietary theme system
- When you need to combine commerce data with a CMS (Contentful, Sanity) at build time
Prerequisites & Platform Notes
This skill is written for custom/headless storefronts (Node.js, Python, or similar backend). The code examples use TypeScript/Node.js and can be adapted to any stack.
Shopify: Shopify Hydrogen is Shopify's headless framework. MACH/composable patterns apply when using Shopify as the commerce backend with a custom frontend, or when mixing Shopify with other best-of-breed services.
WooCommerce: WooCommerce can serve as a headless backend via its REST API and WPGraphQL. These patterns apply when decoupling the frontend from WordPress.
Magento: Magento's GraphQL API and PWA Studio support headless architectures. These composable patterns apply to Magento as a backend service in a MACH stack.
You'll need:
- Node.js 18+ (or adapt to your backend language)
- Redis for caching/queues
- An email sending service (SendGrid, AWS SES, or Postmark)
- CDN (Cloudflare, CloudFront, or Fastly)
Core Instructions
Bootstrap a Next.js commerce storefront
npx create-next-app@latest my-store --typescript --tailwind --app
cd my-store
npm install @shopify/storefront-api-client graphql
Configure the Storefront API client:
// lib/shopify.ts
import {createStorefrontApiClient} from '@shopify/storefront-api-client';
export const shopify = createStorefrontApiClient({
storeDomain: process.env.SHOPIFY_STORE_DOMAIN!,
publicAccessToken: process.env.SHOPIFY_STOREFRONT_TOKEN!,
apiVersion: '2025-01',
});
Statically generate product pages with ISR
// app/products/[handle]/page.tsx (Next.js App Router)
import {shopify} from '@/lib/shopify';
import {notFound} from 'next/navigation';
// ISR: revalidate every 60 seconds
export const revalidate = 60;
// Pre-build top products at build time
export async function generateStaticParams() {
const {data} = await shopify.request(TOP_PRODUCTS_QUERY, {
variables: {first: 200},
});
return data.products.edges.map(({node}: any) => ({handle: node.handle}));
}
export default async function ProductPage({params}: {params: {handle: string}}) {
const {data} = await shopify.request(PRODUCT_QUERY, {
variables: {handle: params.handle},
});
if (!data.product) notFound();
return <ProductDetail product={data.product} />;
}
const PRODUCT_QUERY = `
query ProductByHandle($handle: String!) {
product(handle: $handle) {
id title descriptionHtml
images(first: 5) { edges { node { url altText } } }
variants(first: 20) {
edges { node { id title price { amount currencyCode } availableForSale } }
}
}
}
`;
Build an Astro storefront for minimal JavaScript overhead
Astro ships zero JS by default — components are server-rendered to static HTML unless explicitly hydrated:
npm create astro@latest -- --template minimal
cd my-astro-store
npx astro add tailwind
npm install @astrojs/node graphql-request
---
// src/pages/products/[handle].astro
import {GraphQLClient, gql} from 'graphql-request';
import Layout from '../../layouts/Layout.astro';
import AddToCartButton from '../../components/AddToCartButton.tsx'; // Island
export async function getStaticPaths() {
const client = new GraphQLClient(import.meta.env.SALEOR_API_URL);
const {products} = await client.request(gql`query { products(first: 200, channel: "default-channel") { edges { node { slug } } } }`);
return products.edges.map(({node}: any) => ({params: {handle: node.slug}}));
}
const {handle} = Astro.params;
const client = new GraphQLClient(import.meta.env.SALEOR_API_URL);
const {product} = await client.request(PRODUCT_QUERY, {slug: handle, channel: 'default-channel'});
---
<Layout title={product.name}>
<h1>{product.name}</h1>
<img src={product.thumbnail.url} alt={product.thumbnail.alt} />
<!-- Only this interactive island ships JavaScript -->
<AddToCartButton client:load variantId={product.variants[0].id} />
</Layout>
Implement on-demand ISR webhooks for catalog freshness
When a product is updated in your CMS or commerce platform, trigger Next.js to revalidate only that page:
// app/api/revalidate/route.ts
import {NextRequest, NextResponse} from 'next/server';
import {revalidatePath, revalidateTag} from 'next/cache';
export async function POST(req: NextRequest) {
const authHeader = req.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.REVALIDATION_TOKEN}`) {
return NextResponse.json({error: 'Unauthorized'}, {status: 401});
}
const body = await req.json();
const {type, handle, collectionHandle} = body;
switch (type) {
case 'product':
revalidatePath(`/products/${handle}`);
revalidateTag('products');
break;
case 'collection':
revalidatePath(`/collections/${collectionHandle}`);
revalidateTag('collections');
break;
case 'all':
revalidateTag('products');
revalidateTag('collections');
break;
}
return NextResponse.json({revalidated: true, timestamp: Date.now()});
}
Configure your commerce platform to POST to this endpoint on product updates.
Implement client-side cart with Zustand
Static product pages need client-side cart state. Use lightweight state management with localStorage persistence:
// lib/cart-store.ts
import {create} from 'zustand';
import {persist} from 'zustand/middleware';
interface CartItem {
variantId: string;
title: string;
price: number;
quantity: number;
image: string;
}
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (variantId: string) => void;
updateQuantity: (variantId: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find(i => i.variantId === item.variantId);
if (existing) {
return {items: state.items.map(i => i.variantId === item.variantId ? {...i, quantity: i.quantity + item.quantity} : i)};
}
return {items: [...state.items, item]};
}),
removeItem: (variantId) => set((state) => ({items: state.items.filter(i => i.variantId !== variantId)})),
updateQuantity: (variantId, quantity) => set((state) => ({items: state.items.map(i => i.variantId === variantId ? {...i, quantity} : i)})),
clearCart: () => set({items: []}),
total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{name: 'cart-storage'},
),
);
Configure CDN caching and cache purging
// next.config.ts
export default {
async headers() {
return [
{
source: '/products/:path*',
headers: [
{key: 'Cache-Control', value: 'public, s-maxage=60, stale-while-revalidate=600'},
],
},
{
source: '/api/:path*',
headers: [
{key: 'Cache-Control', value: 'no-store'},
],
},
];
},
images: {
remotePatterns: [
{protocol: 'https', hostname: '**.shopify.com'},
{protocol: 'https', hostname: '**.saleor.io'},
],
},
};
Examples
Next.js 15 App Router with fetch caching tags
// lib/get-product.ts
export async function getProduct(handle: string) {
const res = await fetch(`${process.env.SALEOR_API_URL}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: PRODUCT_QUERY, variables: {slug: handle, channel: 'default-channel'}}),
next: {
revalidate: 300,
tags: [`product-${handle}`, 'products'],
},
});
if (!res.ok) throw new Error(`Failed to fetch product: ${res.status}`);
const {data} = await res.json();
return data.product;
}
Astro with on-demand rendering for cart pages
---
// src/pages/cart.astro
// Opt out of static generation for cart — render on every request
export const prerender = false;
import Layout from '../layouts/Layout.astro';
import CartPage from '../components/CartPage.tsx';
---
<Layout title="Your Cart">
<!-- Full client hydration for interactive cart UI -->
<CartPage client:only="react" />
</Layout>
Best Practices
- Generate static params for only your top N products — generating 100,000 product pages at build time is slow; generate the top 500 and let ISR handle the long tail on first request
- Use Next.js fetch cache tags — tag each fetch with semantic names (
product-${handle}, collections) so revalidation is surgical rather than purging everything
- Separate dynamic from static concerns — product details and images should be static HTML; cart, account, and personalization should be client-rendered islands
- Pre-warm ISR pages after deployment — run a script that hits your top product URLs immediately after deploy to populate the CDN cache before customers arrive
- Use a build-time data layer — fetch all catalog data in a single large batch at build time rather than making N individual API calls for N product pages
- Set
revalidate based on update frequency — flash sale prices need low revalidation (10s); evergreen product descriptions can be 1 hour or more
- Test with Lighthouse in CI — add a Lighthouse CI step to enforce performance budgets so regressions are caught before they reach production
Common Pitfalls
| Problem |
Solution |
| Build times balloon with large catalogs |
Use generateStaticParams only for top products; set dynamicParams = true so the rest are rendered on demand |
| ISR serves stale prices during flash sales |
Use revalidate = 10 for pricing data or fetch price client-side and merge into the static shell |
| Cart state not persisting across page navigations |
Use zustand with persist middleware or a server-side cart stored in a cookie/session |
| Images fail to load in production |
Add all commerce CDN hostnames to next.config.ts images.remotePatterns |
| On-demand revalidation endpoint abused |
Always require a secret token in the Authorization header; rotate the token if the endpoint is publicly discoverable |
Related Skills
- @shopify-hydrogen
- @saleor-development
- @pwa-storefront
- @image-optimization-cdn
- @edge-commerce
1---2name: jamstack-storefront3description: Build a blazing-fast storefront with Next.js or Astro that pre-renders product pages as static HTML and fetches live data from commerce APIs4---56# Jamstack Storefront78## Overview910A Jamstack storefront pre-renders catalog pages at build time for maximum performance and CDN cacheability, while using client-side JavaScript and commerce APIs for dynamic functionality (cart, checkout, account). Next.js with Incremental Static Regeneration (ISR) and Astro with on-demand rendering are the two dominant approaches, each offering different tradeoffs between build times, freshness, and interactivity. This skill covers setting up a Jamstack commerce site, managing catalog regeneration, and integrating headless commerce APIs.1112## When to Use This Skill1314- When SEO and Core Web Vitals scores are top priorities — static HTML scores near-perfect Lighthouse results15- When you have a large catalog that rarely changes and want sub-100ms page loads from CDN16- When you want to decouple the commerce backend (Shopify, Saleor, commercetools) from the storefront deployment cycle17- When your team wants to use modern React/Astro tooling rather than a platform's proprietary theme system18- When you need to combine commerce data with a CMS (Contentful, Sanity) at build time1920## Prerequisites & Platform Notes2122**This skill is written for custom/headless storefronts** (Node.js, Python, or similar backend). The code examples use TypeScript/Node.js and can be adapted to any stack.2324**Shopify**: Shopify Hydrogen is Shopify's headless framework. MACH/composable patterns apply when using Shopify as the commerce backend with a custom frontend, or when mixing Shopify with other best-of-breed services.25**WooCommerce**: WooCommerce can serve as a headless backend via its REST API and WPGraphQL. These patterns apply when decoupling the frontend from WordPress.26**Magento**: Magento's GraphQL API and PWA Studio support headless architectures. These composable patterns apply to Magento as a backend service in a MACH stack.2728**You'll need**:29- Node.js 18+ (or adapt to your backend language)30- Redis for caching/queues31- An email sending service (SendGrid, AWS SES, or Postmark)32- CDN (Cloudflare, CloudFront, or Fastly)3334## Core Instructions35361. **Bootstrap a Next.js commerce storefront**3738 ```bash39 npx create-next-app@latest my-store --typescript --tailwind --app40 cd my-store41 npm install @shopify/storefront-api-client graphql42 ```4344 Configure the Storefront API client:45 ```typescript46 // lib/shopify.ts47 import {createStorefrontApiClient} from '@shopify/storefront-api-client';4849 export const shopify = createStorefrontApiClient({50 storeDomain: process.env.SHOPIFY_STORE_DOMAIN!,51 publicAccessToken: process.env.SHOPIFY_STOREFRONT_TOKEN!,52 apiVersion: '2025-01',53 });54 ```55562. **Statically generate product pages with ISR**5758 ```typescript59 // app/products/[handle]/page.tsx (Next.js App Router)60 import {shopify} from '@/lib/shopify';61 import {notFound} from 'next/navigation';6263 // ISR: revalidate every 60 seconds64 export const revalidate = 60;6566 // Pre-build top products at build time67 export async function generateStaticParams() {68 const {data} = await shopify.request(TOP_PRODUCTS_QUERY, {69 variables: {first: 200},70 });71 return data.products.edges.map(({node}: any) => ({handle: node.handle}));72 }7374 export default async function ProductPage({params}: {params: {handle: string}}) {75 const {data} = await shopify.request(PRODUCT_QUERY, {76 variables: {handle: params.handle},77 });7879 if (!data.product) notFound();8081 return <ProductDetail product={data.product} />;82 }8384 const PRODUCT_QUERY = `85 query ProductByHandle($handle: String!) {86 product(handle: $handle) {87 id title descriptionHtml88 images(first: 5) { edges { node { url altText } } }89 variants(first: 20) {90 edges { node { id title price { amount currencyCode } availableForSale } }91 }92 }93 }94 `;95 ```96973. **Build an Astro storefront for minimal JavaScript overhead**9899 Astro ships zero JS by default — components are server-rendered to static HTML unless explicitly hydrated:100101 ```bash102 npm create astro@latest -- --template minimal103 cd my-astro-store104 npx astro add tailwind105 npm install @astrojs/node graphql-request106 ```107108 ```astro109 ---110 // src/pages/products/[handle].astro111 import {GraphQLClient, gql} from 'graphql-request';112 import Layout from '../../layouts/Layout.astro';113 import AddToCartButton from '../../components/AddToCartButton.tsx'; // Island114115 export async function getStaticPaths() {116 const client = new GraphQLClient(import.meta.env.SALEOR_API_URL);117 const {products} = await client.request(gql`query { products(first: 200, channel: "default-channel") { edges { node { slug } } } }`);118 return products.edges.map(({node}: any) => ({params: {handle: node.slug}}));119 }120121 const {handle} = Astro.params;122 const client = new GraphQLClient(import.meta.env.SALEOR_API_URL);123 const {product} = await client.request(PRODUCT_QUERY, {slug: handle, channel: 'default-channel'});124 ---125126 <Layout title={product.name}>127 <h1>{product.name}</h1>128 <img src={product.thumbnail.url} alt={product.thumbnail.alt} />129 <!-- Only this interactive island ships JavaScript -->130 <AddToCartButton client:load variantId={product.variants[0].id} />131 </Layout>132 ```1331344. **Implement on-demand ISR webhooks for catalog freshness**135136 When a product is updated in your CMS or commerce platform, trigger Next.js to revalidate only that page:137138 ```typescript139 // app/api/revalidate/route.ts140 import {NextRequest, NextResponse} from 'next/server';141 import {revalidatePath, revalidateTag} from 'next/cache';142143 export async function POST(req: NextRequest) {144 const authHeader = req.headers.get('authorization');145 if (authHeader !== `Bearer ${process.env.REVALIDATION_TOKEN}`) {146 return NextResponse.json({error: 'Unauthorized'}, {status: 401});147 }148149 const body = await req.json();150 const {type, handle, collectionHandle} = body;151152 switch (type) {153 case 'product':154 revalidatePath(`/products/${handle}`);155 revalidateTag('products');156 break;157 case 'collection':158 revalidatePath(`/collections/${collectionHandle}`);159 revalidateTag('collections');160 break;161 case 'all':162 revalidateTag('products');163 revalidateTag('collections');164 break;165 }166167 return NextResponse.json({revalidated: true, timestamp: Date.now()});168 }169 ```170171 Configure your commerce platform to POST to this endpoint on product updates.1721735. **Implement client-side cart with Zustand**174175 Static product pages need client-side cart state. Use lightweight state management with localStorage persistence:176177 ```typescript178 // lib/cart-store.ts179 import {create} from 'zustand';180 import {persist} from 'zustand/middleware';181182 interface CartItem {183 variantId: string;184 title: string;185 price: number;186 quantity: number;187 image: string;188 }189190 interface CartStore {191 items: CartItem[];192 addItem: (item: CartItem) => void;193 removeItem: (variantId: string) => void;194 updateQuantity: (variantId: string, quantity: number) => void;195 clearCart: () => void;196 total: () => number;197 }198199 export const useCartStore = create<CartStore>()(200 persist(201 (set, get) => ({202 items: [],203 addItem: (item) => set((state) => {204 const existing = state.items.find(i => i.variantId === item.variantId);205 if (existing) {206 return {items: state.items.map(i => i.variantId === item.variantId ? {...i, quantity: i.quantity + item.quantity} : i)};207 }208 return {items: [...state.items, item]};209 }),210 removeItem: (variantId) => set((state) => ({items: state.items.filter(i => i.variantId !== variantId)})),211 updateQuantity: (variantId, quantity) => set((state) => ({items: state.items.map(i => i.variantId === variantId ? {...i, quantity} : i)})),212 clearCart: () => set({items: []}),213 total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),214 }),215 {name: 'cart-storage'},216 ),217 );218 ```2192206. **Configure CDN caching and cache purging**221222 ```typescript223 // next.config.ts224 export default {225 async headers() {226 return [227 {228 source: '/products/:path*',229 headers: [230 {key: 'Cache-Control', value: 'public, s-maxage=60, stale-while-revalidate=600'},231 ],232 },233 {234 source: '/api/:path*',235 headers: [236 {key: 'Cache-Control', value: 'no-store'},237 ],238 },239 ];240 },241 images: {242 remotePatterns: [243 {protocol: 'https', hostname: '**.shopify.com'},244 {protocol: 'https', hostname: '**.saleor.io'},245 ],246 },247 };248 ```249250## Examples251252### Next.js 15 App Router with fetch caching tags253254```typescript255// lib/get-product.ts256export async function getProduct(handle: string) {257 const res = await fetch(`${process.env.SALEOR_API_URL}`, {258 method: 'POST',259 headers: {'Content-Type': 'application/json'},260 body: JSON.stringify({query: PRODUCT_QUERY, variables: {slug: handle, channel: 'default-channel'}}),261 next: {262 revalidate: 300,263 tags: [`product-${handle}`, 'products'],264 },265 });266267 if (!res.ok) throw new Error(`Failed to fetch product: ${res.status}`);268 const {data} = await res.json();269 return data.product;270}271```272273### Astro with on-demand rendering for cart pages274275```astro276---277// src/pages/cart.astro278// Opt out of static generation for cart — render on every request279export const prerender = false;280281import Layout from '../layouts/Layout.astro';282import CartPage from '../components/CartPage.tsx';283---284285<Layout title="Your Cart">286 <!-- Full client hydration for interactive cart UI -->287 <CartPage client:only="react" />288</Layout>289```290291## Best Practices292293- **Generate static params for only your top N products** — generating 100,000 product pages at build time is slow; generate the top 500 and let ISR handle the long tail on first request294- **Use Next.js fetch cache tags** — tag each fetch with semantic names (`product-${handle}`, `collections`) so revalidation is surgical rather than purging everything295- **Separate dynamic from static concerns** — product details and images should be static HTML; cart, account, and personalization should be client-rendered islands296- **Pre-warm ISR pages after deployment** — run a script that hits your top product URLs immediately after deploy to populate the CDN cache before customers arrive297- **Use a build-time data layer** — fetch all catalog data in a single large batch at build time rather than making N individual API calls for N product pages298- **Set `revalidate` based on update frequency** — flash sale prices need low revalidation (10s); evergreen product descriptions can be 1 hour or more299- **Test with Lighthouse in CI** — add a Lighthouse CI step to enforce performance budgets so regressions are caught before they reach production300301## Common Pitfalls302303| Problem | Solution |304|---------|----------|305| Build times balloon with large catalogs | Use `generateStaticParams` only for top products; set `dynamicParams = true` so the rest are rendered on demand |306| ISR serves stale prices during flash sales | Use `revalidate = 10` for pricing data or fetch price client-side and merge into the static shell |307| Cart state not persisting across page navigations | Use `zustand` with `persist` middleware or a server-side cart stored in a cookie/session |308| Images fail to load in production | Add all commerce CDN hostnames to `next.config.ts` `images.remotePatterns` |309| On-demand revalidation endpoint abused | Always require a secret token in the `Authorization` header; rotate the token if the endpoint is publicly discoverable |310311## Related Skills312313- @shopify-hydrogen314- @saleor-development315- @pwa-storefront316- @image-optimization-cdn317- @edge-commerce