mcpField
J
jamstack-storefront

jamstack-storefront

github.com/finsilabs开发6078 次安装6 天前更新
代码仓库

关于 jamstack-storefront

这项开发技能介绍如何构建 Jamstack 电商店铺,以静态页面呈现商品目录,并动态处理交易功能。适用于采用自定义无头架构的团队,涵盖 Next.js 或 Astro 渲染、目录更新和 CDN 缓存。

主要功能

  1. 01配置 Next.js 和 Astro 店铺
  2. 02静态生成商品目录
  3. 03按需重新验证页面
  4. 04客户端购物车状态
  5. 05配置 CDN 缓存

适用场景

  1. 01构建无头电商店铺
  2. 02通过 CDN 提供商品页
  3. 03目录更新后刷新页面
  4. 04保持购物车功能动态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill jamstack-storefront

安装后在 Claude Code 中输入/jamstack-storefront确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

tile.json
evals/isr-webhook-revalidation-with-cache-tags/
criteria.json
task.md
evals/next-js-shopify-isr-product-pages/
criteria.json
task.md
evals/zustand-cart-with-localstorage-persisten/
criteria.json
task.md

详细介绍

Jamstack 店面

概述

Jamstack 店面会在构建时预渲染商品目录页面,以实现最佳性能和 CDN 缓存能力,同时使用客户端 JavaScript 和电商 API 提供动态功能(购物车、结账、账户)。Next.js 的增量静态再生(ISR)和 Astro 的按需渲染是两种主流方案,各自在构建时间、内容新鲜度和交互性方面有所取舍。本技能介绍如何搭建 Jamstack 电商网站、管理商品目录再生成,以及集成无头电商 API。

适用场景

  • 当 SEO 和 Core Web Vitals 评分是首要任务时——静态 HTML 的 Lighthouse 评分几近满分
  • 当你拥有很少变动的大型商品目录,并希望通过 CDN 实现低于 100 ms 的页面加载时间时
  • 当你希望将电商后端(Shopify、Saleor、commercetools)与店面部署周期解耦时
  • 当团队希望使用现代 React/Astro 工具,而不是平台专有的主题系统时
  • 当你需要在构建时将电商数据与 CMS(Contentful、Sanity)结合时

前提条件和平台说明

本技能适用于自定义/无头店面(Node.js、Python 或类似后端)。代码示例使用 TypeScript/Node.js,可适配任何技术栈。

Shopify:Shopify Hydrogen 是 Shopify 的无头框架。使用 Shopify 作为电商后端并搭配自定义前端,或将 Shopify 与其他同类最佳服务结合时,可采用 MACH/可组合模式。 WooCommerce:WooCommerce 可通过其 REST API 和 WPGraphQL 作为无头后端。在将前端与 WordPress 解耦时,可采用这些模式。 Magento:Magento 的 GraphQL API 和 PWA Studio 支持无头架构。在 MACH 技术栈中将 Magento 用作后端服务时,可采用这些可组合模式。

你需要:

  • Node.js 18+(或改用你的后端语言)
  • 用于缓存/队列的 Redis
  • 邮件发送服务(SendGrid、AWS SES 或 Postmark)
  • CDN(Cloudflare、CloudFront 或 Fastly)

核心说明

  1. 初始化 Next.js 电商店面

    npx create-next-app@latest my-store --typescript --tailwind --app
    cd my-store
    npm install @shopify/storefront-api-client graphql
    

    配置 Storefront API 客户端:

    // 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',
    });
    
  2. 使用 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 } }
          }
        }
      }
    `;
    
  3. 构建 JavaScript 开销最小的 Astro 店面

    Astro 默认不会发送任何 JS——组件会被服务端渲染为静态 HTML,除非明确进行水合:

    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} />
      
      <AddToCartButton client:load variantId={product.variants[0].id} />
    </Layout>
    
  4. 实现按需 ISR Webhook,确保商品目录内容保持最新

    在 CMS 或电商平台中更新商品时,触发 Next.js 仅重新验证该页面:

    // 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()});
    }
    

    将电商平台配置为在商品更新时向此端点发送 POST 请求。

  5. 使用 Zustand 实现客户端购物车

    静态商品页面需要客户端购物车状态。使用轻量级状态管理,并通过 localStorage 持久化:

    // 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'},
      ),
    );
    
  6. 配置 CDN 缓存和缓存清除

    // 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'},
        ],
      },
    };
    

示例

使用 fetch 缓存标签的 Next.js 15 App Router
// 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
---
// 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">
  
  <CartPage client:only="react" />
</Layout>

最佳实践

  • 仅为排名前 N 的产品生成静态参数 — 在构建时生成 100,000 个产品页面会很慢;生成排名前 500 个页面,让 ISR 在长尾页面首次请求时处理
  • 使用 Next.js fetch 缓存标签 — 为每次 fetch 添加语义化名称作为标签(product-${handle}、collections),这样可以精准重新验证,而不是清除所有缓存
  • 将动态内容与静态内容分开 — 产品详情和图片应使用静态 HTML;购物车、账户和个性化内容应采用客户端渲染的岛屿
  • 部署后预热 ISR 页面 — 运行脚本,在部署后立即请求排名靠前的产品 URL,在客户访问前填充 CDN 缓存
  • 使用构建时数据层 — 在构建时一次性批量获取所有目录数据,而不是为 N 个产品页面分别发起 N 次 API 调用
  • 根据更新频率设置 revalidate — 限时促销价格需要较短的重新验证间隔(10s);常青产品描述可以设置为 1 小时或更长
  • 在 CI 中使用 Lighthouse 测试 — 添加 Lighthouse CI 步骤来强制执行性能预算,以便在性能回归进入生产环境前及时发现

常见问题

问题解决方案
产品目录较大时,构建时间急剧增加仅使用 generateStaticParams 为热门产品生成页面;设置 dynamicParams = true,让其余页面按需渲染
限时促销期间,ISR 提供的价格过时为价格数据设置 revalidate = 10,或在客户端获取价格并合并到静态页面框架中
页面导航后购物车状态未保留使用带有 persist 中间件的 zustand,或使用存储在 cookie/session 中的服务端购物车
图片在生产环境中无法加载将所有电商 CDN 主机名添加到 next.config.ts 的 images.remotePatterns 中
按需重新验证端点遭到滥用始终要求在 Authorization 标头中提供密钥令牌;如果端点可被公开发现,请轮换令牌

相关技能

  • @shopify-hydrogen
  • @saleor-development
  • @pwa-storefront
  • @image-optimization-cdn
  • @edge-commerce

评论

支持 Markdown