最近帮朋友做了个有趣的项目:他想在 Shopify 上卖产品,但需要一个自定义的配置环节。于是,我用 Next.js 16 写了一个手链 DIY 配置器,部署在 Vercel 上,而主站则继续运行在 Shopify 上。整个流程是:客户进入 DIY 页面选珠子、设计手串、点击“完成”——前端调用 Shopify 的 Storefront API 创建购物车,然后将用户跳转到 Shopify 的原生结账页面完成付款。当这一切跑通时,确实让人兴奋:一个自建的 Next.js 应用,就这样无缝对接上了一套成熟的 SaaS 电商系统。
这其实就是业界所谓的 Headless Commerce(无头电商)——让 Shopify 负责商品、库存、订单、支付、物流这些“电商基础设施”,而我们的 Next.js 应用则专注于实现差异化的交互和私有业务逻辑(比如这个 DIY 配置器)。分工明确,各取所长。当然,这套方案也适用于其他想在 Shopify 上对接独立 Web 应用的场景。
然而,实际的对接过程比预想的花了更多功夫。Shopify 在 2025 年底砍掉了老版的“自定义应用”流程,强制所有开发者转向 Dev Dashboard + OAuth 的新模式。官方文档刚更新、界面全新,网上大部分老教程都已失效。这篇文章旨在完整走一遍这条新路径,并把关键代码放出来,希望能帮助正在做类似事情的朋友少绕点弯路。
整体架构设计
先明确一下三方分工:
┌─────────────────┐ Storefront API (GraphQL)
│ diy.xxxx.com │ ──────────────────────────────┐
│ Next.js 16 │ ▼
│ (Vercel) │ ┌──────────────────┐
│ │ ◀───── Webhook ────│ Shopify 店铺 │
│ /builder │ │ xxxxxh-me │
│ /admin/beads │ ──── Admin API ───▶│ .myshopify.com │
└─────────────────┘ └──────────────────┘
▲ │
│ ▼
│ 用户 ──► lxxxx.com/checkouts/...
└──────── 跳转回 ────────┘ ▲
│
Shopify 原生 checkout
- DIY 前端 运行在 Vercel,使用 Storefront API 拉取商品(珠子)、生成购物车链接。
- Shopify 店铺 负责商品库存、订单、支付、物流,这些成熟的模块我们不再重复开发。
- Admin API 让我们自己的
/admin 页面可以管理“珠子”这类自定义商品数据。
- Webhook 让 Shopify 在商品/库存发生变动时,能够通知 Vercel 刷新缓存。
客户从独立站选完商品后,跳回主站结账,域名和品牌保持统一(*.xxxx.com)。支付环节直接使用 Shopify 的 checkout,也无需自己操心 PCI 合规问题。可谓一举多得。
第一个挑战:Shopify 的变革,Dev Dashboard 成为唯一路径
进入 Shopify 后台搜索“开发应用”,你会看到一段直白的提示:
Dev Dashboard 是您全新的应用开发平台,与遗留自定义应用相比,它提供了更多的特性和工具。
老手熟悉的“遗留自定义应用”流程——创建应用、勾选权限、一键生成 shpat_xxx token——这条路已被官方明确弃用。或者说,它不再是主流的推荐路线,迟早会面临整改下线。
新的路径是:在 Dev Dashboard 里创建应用 → 创建版本 → 发布 → 安装到店铺 → 通过 OAuth code exchange 换取 token。流程复杂了三四倍,但方向是对的:采用标准化的 OAuth、分离 Client ID 与 Secret、token 可吊销、权限范围可渐进式升级。
在版本创建的表单里,其实还藏着一个“使用旧版安装流程”的勾选框,勾上还能使用老方式。我第一反应是勾上省事,但很快打消了这个念头——Shopify 自己都标注了“deprecated”,今天偷懒明天就得重写。既然早晚要做,不如一次做对。
OAuth 授权码流程的实现
新流程官方推荐的是 Authorization Code Grant,整个数据流如下:
浏览器 ──► /api/shopify/install (我们的路由)
──► 写 state cookie(CSRF 防御)
──► 302 到 https://{shop}/admin/oauth/authorize?...
用户 ──► 看到 scope 列表,点“安装”
Shopify ──► 302 回 /api/shopify/callback?code=&hmac=&state=&shop=
我们 ──► 校验 shop 白名单
──► 校验 state(timing-safe)
──► 校验 hmac (timing-safe, hex digest)
──► POST {shop}/admin/oauth/access_token
body: {client_id, client_secret, code}
◀── { access_token, scope }
──► 用 access_token 调 storefrontAccessTokenCreate mutation
◀── storefront token
──► 渲染 HTML 一次性展示两个 token
我翻看了一下现有的业务代码,两个 Shopify client 都只是从环境变量里读取 token:
// src/lib/shopify/admin-client.ts
function getAdminConfig() {
const domain = process.env.SHOPIFY_DOMAIN ?? "";
const token = process.env.SHOPIFY_ADMIN_TOKEN ?? "";
if (!domain || !token) {
throw new Error("Missing Shopify domain or admin token");
}
return {
endpoint: `https://${domain}/admin/api/2026-04/graphql.json`,
token,
};
}
关键点来了:只要能把正确的 token 搞进环境变量,现有的业务代码就完全不需要改动。 所以,我们只需要写一个一次性的 OAuth 回调流程来获取 token。
Install 路由
// src/app/api/shopify/install/route.ts
export async function GET(request: Request) {
const url = new URL(request.url);
const shop =
url.searchParams.get("shop") ?? process.env.SHOPIFY_DOMAIN ?? "";
if (!isAllowedShop(shop)) {
return NextResponse.json({ error: "Shop is not allowed." }, { status: 400 });
}
const clientId = process.env.SHOPIFY_APP_CLIENT_ID!;
const scopes = process.env.SHOPIFY_APP_SCOPES!;
const redirectUri = `${url.origin}/api/shopify/callback`;
const state = randomBytes(32).toString("hex");
const cookieStore = await cookies();
cookieStore.set("shopify_oauth_state", state, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 5 * 60,
path: "/api/shopify",
});
return NextResponse.redirect(
buildInstallUrl({ shop, clientId, scopes, redirectUri, state }),
{ status: 302 },
);
}
state 是一个 32 字节的随机 hex 字符串,通过 cookie 存储并限定在 /api/shopify 路径下,设置 SameSite=Lax 以确保 Shopify 跳转回来时 cookie 能被带回。
Callback 路由(最核心)
export async function GET(request: Request) {
const params = new URL(request.url).searchParams;
const shop = params.get(“shop”);
const code = params.get(“code”);
const state = params.get(“state”);
// 1. shop 白名单——防止别的店铺打到我们的回调
if (!isAllowedShop(shop)) {
return NextResponse.json({ error: “Shop is not allowed.” }, { status: 400 });
}
// 2. state 校验——防 CSRF
const cookieStore = await cookies();
const savedState = cookieStore.get(“shopify_oauth_state”)?.value;
if (!savedState || !safeStateCompare(savedState, state!)) {
return NextResponse.json({ error: “Invalid OAuth state.” }, { status: 400 });
}
// 3. HMAC 校验——证明这个回调确实来自 Shopify
if (!verifyOAuthHmac(params, process.env.SHOPIFY_APP_CLIENT_SECRET!)) {
return NextResponse.json({ error: “Invalid HMAC signature.” }, { status: 401 });
}
// 4. code 换 access_token
const tokenResult = await exchangeCodeForToken({
shop: shop!,
code: code!,
clientId: process.env.SHOPIFY_APP_CLIENT_ID!,
clientSecret: process.env.SHOPIFY_APP_CLIENT_SECRET!,
});
// 5. 用 admin token 生成 storefront token
const { token: storefrontToken } = await createStorefrontToken(
shop!,
tokenResult.access_token,
);
// 6. 渲染 HTML 给用户复制
return new Response(renderTokenPage({ ... }), {
status: 200,
headers: {
“Content-Type”: “text/html; charset=utf-8”,
“Cache-Control”: “no-store”,
“Referrer-Policy”: “no-referrer”,
},
});
}
三层校验顺序必须严格:shop → state → hmac,任何一层失败都应立刻终止流程。其中,state 和 hmac 的比较需要使用时间恒定比较(crypto.timingSafeEqual)来防止定时攻击。
注意:HMAC 的两种编码
对接过程中踩到了一个有点隐蔽的细节:Shopify 在两处不同场景都要求验签,但使用的编码方式却不一样。
| 场景 |
输入 |
Digest 编码 |
对比来源 |
| Webhook 推送 |
请求的 raw body |
base64 |
X-Shopify-Hmac-SHA256 header |
| OAuth callback |
排序后的 query string,& 拼接 |
hex |
hmac query 参数 |
两段代码放在一起看就一目了然了:
// Webhook 验签:base64
const digest = createHmac(“sha256”, secret)
.update(rawBody, “utf8”)
.digest(“base64”);
// OAuth 验签:hex
const entries: [string, string][] = [];
for (const [key, value] of searchParams.entries()) {
if (key === “hmac” || key === “signature”) continue;
entries.push([key, value]);
}
entries.sort(([a], [b]) => (a < b ? -1 : 1));
const message = entries.map(([k, v]) => `${k}=${v}`).join(“&”);
const digest = createHmac(“sha256”, secret)
.update(message, “utf8”)
.digest(“hex”);
两处比较都应使用 crypto.timingSafeEqual。
这种细节分布在官方文档的不同 URL 里(webhooks/subscribe/https 和 access-tokens/authorization-code-grant),第一次对接很容易把 base64 的逻辑套到 OAuth 上,或者反过来——导致签名怎么都对不上。查清官方文档、明确两种编码的边界之后,才能一次写对。
Token 的一次性展示 UI
新流程没有官方的“展示 token”页面——token 是 OAuth 流程中的临时产物,由开发者自行决定如何存储。
对于单店铺场景,最轻量的做法是:在回调里渲染一个极简的 HTML 页面,将 Admin token 和 Storefront token 分别显示,并各配一个“复制”按钮,旁边写清楚对应的环境变量名。用户将它们粘贴到 Vercel 的环境变量配置中,重新部署一次,对接就完成了。之后这个 OAuth 路径就可以下线。
return new Response(
renderTokenPage({
shop,
adminToken: tokenResult.access_token,
storefrontToken,
grantedScopes: tokenResult.scope,
}),
{
headers: {
“Content-Type”: “text/html; charset=utf-8”,
“Cache-Control”: “no-store”, // 不进浏览器缓存
“Referrer-Policy”: “no-referrer”, // 不通过 Referer 泄漏
},
},
);
多店铺的 SaaS 场景另当别论,token 需要持久化到数据库并带上店铺维度的索引。但对于单店,直接用环境变量最轻量,后续需要扩展时再迁移也不迟。
一个让我印象深刻的错误
token 配齐、重新部署完成后,我兴奋地打开 /builder 页面——能看到珠子了!但点击“完成设计”后,却报错了:
ID 为 gid://shopify/ProductVariant/0 的商品不存在。
0?哪来的 0?页面上明明能看到 4 颗珠子,价格、颜色都对啊。
翻看代码找到了原因。问题的根源在于,我们的商品需要先在 Shopify 后台手动添加。
// src/lib/load-beads.ts(修复前)
export async function loadBeads(): Promise<Bead[]> {
try {
const beads = await getBeadsFromShopify();
if (beads.length > 0) return beads;
return mockBeads; // ← 静默回落到 mock 数据
} catch {
return mockBeads;
}
}
店铺里还没有打上 bracelet-bead 标签的真实商品,Shopify 返回空数组 → 代码静默回落到 mockBeads。而 mock 数据里的 variant ID 是这样的:
id: “gid://shopify/ProductVariant/mock-white-8”
Shopify 的 GID 解析器期望最后一段是数字,碰到 mock-white-8 会直接解析成 0。所以前端看到的是“假珠子”,送到后端才爆出 ProductVariant/0 不存在 的错误。
修正方法:
function isShopifyConfigured(): boolean {
return Boolean(
process.env.SHOPIFY_DOMAIN && process.env.SHOPIFY_STOREFRONT_TOKEN,
);
}
export async function loadBeads(): Promise<Bead[]> {
if (!isShopifyConfigured()) return mockBeads;
try {
return await getBeadsFromShopify(); // 空数组也照样返回,不再回落
} catch (error) {
console.error(“[load-beads] Shopify fetch failed:”, error);
return [];
}
}
这个教训很朴素:生产环境的 fallback 机制必须足够清晰。“悄悄用 mock 数据顶一下”这种好心的设计,只会让错误排查变得更困难——一个空页面至少是一个明确的信号,而假数据流入真实后端只会产生诡异的错误。
在架构上的几个小决策
1. 客户端不直连 Shopify
很多 Headless 教程会让客户端直接使用 NEXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN 调用 Shopify,省去一层代理。我们反其道而行:客户端只调用 /api/shopify/cart 这类自家的路由,token 完全不离开服务端。
结果就是:.env 文件里一个 NEXT_PUBLIC_* 前缀的变量都没有,泄漏风险面最小。代价是多了一次网络请求,但在 Vercel 边缘节点和 Shopify CDN 的加持下,增加的延迟几乎感知不到。
2. Admin 与 Storefront 两个 Token 严格分离
Admin token 权限很大(能读写商品、文件、订单),我们只允许它在服务端的 /admin/* 路由里使用。Storefront API token 的权限则被限定在 unauthenticated_* 范围内,即使泄漏也只能进行只读查询和未认证的结账操作。
这种分离并非多此一举,而是 Shopify 权限体系的有意设计。
3. API version 对齐
Dev Dashboard 中 Webhook 的 API 版本是 2026-04,我们的三个客户端(admin、storefront、callback)也全部统一使用 2026-04,避免因字段差异引发的莫名 GraphQL 报错。
最终的环境变量清单
跑通整个流程,只需要这 7 个环境变量(全部位于服务端):
# 部署前填写
SHOPIFY_DOMAIN=xxxx-me.myshopify.com
SHOPIFY_APP_CLIENT_ID=...
SHOPIFY_APP_CLIENT_SECRET=...
SHOPIFY_APP_SCOPES=read_products,write_products,...
# OAuth 回调页面拿到后回填
SHOPIFY_ADMIN_TOKEN=shpat_...
SHOPIFY_STOREFRONT_TOKEN=...
# Webhook 创建后回填
SHOPIFY_WEBHOOK_SECRET=...
此外,在 Shopify 后台还需要完成以下几件事:
- Dev Dashboard:创建应用、发布版本(勾选所需权限、填写重定向 URL
https://diy.xxxx.com/api/shopify/callback)。
- 产品元字段 (Metafields):定义
bead.color_hex、bead.category、bead.texture_url,并开启 Storefront API 访问权限。
- 创建商品:创建打了
bracelet-bead 标签的真实珠子商品,包含 variants、库存和上一步定义的 metafields。
- Webhook:创建 Webhook,指向
https://diy.xxxx.com/api/shopify/webhooks。

全部打通后,流程闭环:用户在 diy.xxxx.com/builder 选珠 → 跳转至 Shopify checkout → 支付成功 → Shopify 推送 webhook 回 Vercel → 缓存同步。DIY 交互和电商基础设施各司其职,后续的营销活动、订单统计、库存补货都能直接复用 Shopify 自身成熟的一整套工具。
写在最后
这次对接最大的感受不是“技术本身有多难”——HMAC、OAuth、GraphQL 这些东西,用过 Next.js 或 Express 几年的人都不陌生。真正的难点在于 Shopify 自身的文档和界面正处于快速切换期,新旧流程并存:
- 后台里“开发应用”的按钮明明还亮着,点进去却被引导到全新的 Dev Dashboard。
- Dev Dashboard 的版本创建表单里,还保留着“使用旧版安装流程”的勾选框,勾上能用但已标注为废弃。
- StackOverflow、掘金、CSDN 上 2020~2023 年的大量教程,其流程早已失效。
- 官方的
shopify.dev 文档分散在好几个 URL 中,需要开发者自己拼凑出完整路径。
从零开始,完整走完一次与 Shopify 独立站的对接,大约花了一天时间。回过头看,真正的核心代码量也就三个文件、两百行左右——大部分时间都花在了理清 Shopify 自身新旧流程的边界上。
这篇记录希望能对你有用。如果你也在进行类似的集成,欢迎来 云栈社区 交流探讨。有了这次经验,下次再做类似的事情,估计能节省一半的时间。