Next.js + Shopify OAuth 第三方应用接入完整指南
本文将详细介绍如何使用 Next.js 14 (App Router) 创建一个非嵌入式 Shopify 应用,实现从 OAuth 认证到获取商品列表的完整流程。
🎯 项目概述
- 技术栈: Next.js 14 + TypeScript + Tailwind CSS
- 架构: 非嵌入式应用 + 无服务器函数
- 认证: OAuth 2.0 流程
- 存储: 本地文件系统会话存储
📋 前置准备
1. Shopify Partner 账户设置
bash
# 1. 访问 Shopify Partner Dashboard
https://partners.shopify.com/
# 2. 创建新应用
- 应用类型: Public app
- 应用名称: 自定义
- 应用URL: https://your-domain.com
- 重定向URL: https://your-domain.com/api/auth/callback
2. 开发环境准备
bash
# 创建 Next.js 项目
npx create-next-app@latest shopify-app --typescript --tailwind --app
# 安装 Shopify 依赖
npm install @shopify/shopify-api
🔧 核心实现
1. 环境变量配置
bash
# .env.local
SHOPIFY_API_KEY=your_api_key #从你的shopify app 设置界面获取
SHOPIFY_API_SECRET=your_api_secret #从你的shopify app 设置界面设置
SHOPIFY_SCOPES=read_products,read_orders
SHOPIFY_HOST=https://your-domain.com # 我们应用的域名
2. Shopify 配置初始化
typescript
// src/lib/shopify.ts
import { shopifyApi, LATEST_API_VERSION } from '@shopify/shopify-api';
export const shopify = shopifyApi({
apiKey: process.env.SHOPIFY_API_KEY!,
apiSecretKey: process.env.SHOPIFY_API_SECRET!,
scopes: process.env.SHOPIFY_SCOPES!.split(','),
hostName: process.env.SHOPIFY_HOST!.replace(/https?:\/\//, ''),
apiVersion: LATEST_API_VERSION,
isEmbeddedApp: false, // 非嵌入式应用
});
3. 会话存储简易实现
shopify 是标准的 OAuth2.0 授权模型,获取的 access_token 这里简单存到文件里,用来实现后边请求商品列表等资源接口,实际也可用用数据库或者redis做持久化存储和缓存管理
typescript
// src/lib/session-storage.ts
import fs from 'fs';
import path from 'path';
const SESSIONS_DIR = path.join(process.cwd(), '.sessions');
interface SessionData {
id: string;
shop: string;
accessToken: string;
expires?: Date;
}
export class FileSessionStorage {
static async storeSession(session: SessionData): Promise<boolean> {
try {
if (!fs.existsSync(SESSIONS_DIR)) {
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
}
const filePath = path.join(SESSIONS_DIR, `${session.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
return true;
} catch (error) {
console.error('存储会话失败:', error);
return false;
}
}
static async loadSession(id: string): Promise<SessionData | undefined> {
try {
const filePath = path.join(SESSIONS_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) return undefined;
const data = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(data);
} catch (error) {
console.error('加载会话失败:', error);
return undefined;
}
}
}
4. OAuth 认证流程
认证入口 API
商家提供店铺名称,调用该接口触发认证流程
typescript
// src/app/api/auth/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const shop = searchParams.get('shop');
if (!shop) {
return NextResponse.json({ error: '缺少shop参数' }, { status: 400 });
}
// 构建 OAuth 授权 URL
const scopes = process.env.SHOPIFY_SCOPES!;
const redirectUri = `${process.env.SHOPIFY_HOST}/api/auth/callback`;
const state = Math.random().toString(36).substring(7);
const authUrl = `https://${shop}/admin/oauth/authorize?` +
`client_id=${process.env.SHOPIFY_API_KEY}&` +
`scope=${scopes}&` +
`redirect_uri=${redirectUri}&` +
`state=${state}`;
// 重定向到商家店铺的授权页面,商家点击安装,完成授权
return NextResponse.redirect(authUrl);
}
OAuth 回调处理
授权完成获取code,服务端拿code获取 access_token, access_token 可以用来访问 shopify 数据
typescript
// src/app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { FileSessionStorage } from '@/lib/session-storage';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const shop = searchParams.get('shop');
if (!code || !shop) {
return NextResponse.redirect('/error?message=授权失败');
}
try {
// 交换访问令牌
const tokenResponse = await fetch(`https://${shop}/admin/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.SHOPIFY_API_KEY,
client_secret: process.env.SHOPIFY_API_SECRET,
code,
}),
});
const { access_token } = await tokenResponse.json();
// 存储会话
const sessionId = `${shop}_session`; // sessionId是会话token,用来保持登录态
await FileSessionStorage.storeSession({
id: sessionId,
shop,
accessToken: access_token,
});
// 重定向到商品页面
const response = NextResponse.redirect(`${process.env.SHOPIFY_HOST}/products`);
response.cookies.set('shopify_session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7天
});
return response;
} catch (error) {
console.error('OAuth回调错误:', error);
return NextResponse.redirect('/error?message=授权处理失败');
}
}
5. 商品数据获取
Shopify 使用 GraphQL 语法请求接口
typescript
// src/app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { FileSessionStorage } from '@/lib/session-storage';
const PRODUCTS_QUERY = `
query getProducts($first: Int!) {
products(first: $first) {
edges {
node {
id
title
handle
status
createdAt
updatedAt
priceRangeV2 {
minVariantPrice {
amount
currencyCode
}
}
featuredImage {
url
altText
}
}
}
}
}
`;
export async function GET(request: NextRequest) {
try {
const sessionId = request.cookies.get('shopify_session')?.value;
if (!sessionId) {
return NextResponse.json({ error: '未找到会话' }, { status: 401 });
}
const session = await FileSessionStorage.loadSession(sessionId);
if (!session) {
return NextResponse.json({ error: '会话已过期' }, { status: 401 });
}
// 调用 Shopify GraphQL API
const response = await fetch(`https://${session.shop}/admin/api/2024-01/graphql.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': session.accessToken,
},
body: JSON.stringify({
query: PRODUCTS_QUERY,
variables: { first: 50 },
}),
});
const data = await response.json();
if (data.errors) {
throw new Error(`GraphQL错误: ${JSON.stringify(data.errors)}`);
}
return NextResponse.json({
success: true,
products: data.data.products.edges.map((edge: any) => edge.node),
shop: session.shop,
});
} catch (error) {
console.error('获取商品失败:', error);
return NextResponse.json(
{ error: '获取商品失败', details: error.message },
{ status: 500 }
);
}
}
6. 前端页面实现
店铺连接页面
tsx
// src/app/shopify/page.tsx
'use client';
import { useState } from 'react';
export default function ShopifyPage() {
const [shopUrl, setShopUrl] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const handleConnect = async () => {
if (!shopUrl.trim()) return;
setIsConnecting(true);
// 标准化店铺URL
let shop = shopUrl.trim();
if (!shop.includes('.myshopify.com')) {
shop = `${shop}.myshopify.com`;
}
// 跳转到OAuth认证
window.location.href = `/api/auth?shop=${encodeURIComponent(shop)}`;
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md">
<h1 className="text-2xl font-bold text-gray-900 mb-6 text-center">
连接 Shopify 店铺
</h1>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
店铺地址
</label>
<input
type="text"
value={shopUrl}
onChange={(e) => setShopUrl(e.target.value)}
placeholder="mystore 或 mystore.myshopify.com"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isConnecting}
/>
</div>
<button
onClick={handleConnect}
disabled={!shopUrl.trim() || isConnecting}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isConnecting ? '连接中...' : '连接店铺'}
</button>
</div>
</div>
</div>
);
}
商品列表页面
tsx
// src/app/products/page.tsx
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
interface Product {
id: string;
title: string;
status: string;
priceRangeV2: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
featuredImage?: {
url: string;
altText?: string;
};
}
export default function ProductsPage() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [shop, setShop] = useState('');
const fetchProducts = async () => {
try {
setLoading(true);
const response = await fetch('/api/products');
const data = await response.json();
if (data.success) {
setProducts(data.products);
setShop(data.shop);
} else {
console.error('获取商品失败:', data.error);
}
} catch (error) {
console.error('请求失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProducts();
}, []);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">加载商品中...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">商品列表</h1>
<p className="text-gray-600 mt-2">店铺: {shop}</p>
</div>
<button
onClick={fetchProducts}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
刷新
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{products.map((product) => (
<div key={product.id} className="bg-white rounded-lg shadow-md overflow-hidden">
{product.featuredImage && (
<div className="aspect-square relative">
<Image
src={product.featuredImage.url}
alt={product.featuredImage.altText || product.title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-4">
<h3 className="font-semibold text-gray-900 mb-2 line-clamp-2">
{product.title}
</h3>
<div className="flex justify-between items-center">
<span className="text-lg font-bold text-green-600">
{product.priceRangeV2.minVariantPrice.currencyCode} {product.priceRangeV2.minVariantPrice.amount}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
product.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{product.status}
</span>
</div>
</div>
</div>
))}
</div>
{products.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">暂无商品</p>
</div>
)}
</div>
</div>
);
}