開發者指南框架整合
Next.js 整合指南
在 Next.js App Router 中整合 Recur 訂閱功能
本指南適用於 Next.js 13+ 的 App Router。Recur 提供 React Hooks 和 Server SDK,讓你可以輕鬆整合訂閱功能。
安裝
npm install recur-tw設定環境變數
在 .env.local 中加入:
# 前端使用(可公開)
NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY=pk_test_xxx
# 後端使用(保密)
RECUR_SECRET_KEY=sk_test_xxx安全提醒:RECUR_SECRET_KEY 絕對不可在前端使用。只有 NEXT_PUBLIC_ 開頭的變數才會被打包到前端。
設定 Provider
在 app/providers.tsx 中設定 RecurProvider:
'use client';
import { RecurProvider } from 'recur-tw';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<RecurProvider
config={{
publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY!,
}}
>
{children}
</RecurProvider>
);
}在 app/layout.tsx 中使用:
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}結帳功能
使用 useRecur Hook
'use client';
import { useRecur } from 'recur-tw';
export function SubscribeButton({ productId }: { productId: string }) {
const { checkout, isCheckingOut } = useRecur();
const handleSubscribe = async () => {
await checkout({
productId,
customerEmail: 'user@example.com',
customerName: '王小明',
onPaymentComplete: (result) => {
console.log('訂閱成功!', result);
// 導向成功頁面
window.location.href = '/success';
},
onError: (error) => {
alert('錯誤:' + error.message);
},
});
};
return (
<button onClick={handleSubscribe} disabled={isCheckingOut}>
{isCheckingOut ? '處理中...' : '立即訂閱'}
</button>
);
}Redirect 模式
導向 Recur 託管的結帳頁面:
'use client';
import Recur from 'recur-tw';
export function CheckoutButton({ productId }: { productId: string }) {
const handleCheckout = async () => {
const recur = Recur.init({
publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY!,
});
await recur.redirectToCheckout({
productId,
successUrl: `${window.location.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${window.location.origin}/pricing`,
});
};
return <button onClick={handleCheckout}>前往結帳</button>;
}取得產品列表
Client Component
'use client';
import { useRecur } from 'recur-tw';
import { useEffect, useState } from 'react';
export function PricingTable() {
const { fetchProducts } = useRecur();
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts({ type: 'SUBSCRIPTION' }).then(({ products }) => {
setProducts(products);
});
}, []);
return (
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<PricingCard key={product.id} product={product} />
))}
</div>
);
}Server Component(推薦)
使用 Server SDK 在伺服器端取得資料,更快、更安全:
// app/pricing/page.tsx
import { Recur } from 'recur-tw/server';
const recur = new Recur({
secretKey: process.env.RECUR_SECRET_KEY!,
});
export default async function PricingPage() {
const { products } = await recur.products.list({ type: 'SUBSCRIPTION' });
return (
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<PricingCard key={product.id} product={product} />
))}
</div>
);
}Customer Portal
方法 1:Server Action(推薦)
// app/actions/portal.ts
'use server';
import { Recur } from 'recur-tw/server';
import { redirect } from 'next/navigation';
const recur = new Recur({
secretKey: process.env.RECUR_SECRET_KEY!,
});
export async function redirectToPortal(customerId: string) {
const session = await recur.portal.sessions.create({
customerId,
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(session.url);
}// app/account/page.tsx
import { redirectToPortal } from '@/app/actions/portal';
export default function AccountPage() {
return (
<form action={redirectToPortal.bind(null, 'cus_xxx')}>
<button type="submit">管理訂閱</button>
</form>
);
}方法 2:Route Handler
// app/api/portal/route.ts
import { Recur } from 'recur-tw/server';
import { NextRequest, NextResponse } from 'next/server';
const recur = new Recur({
secretKey: process.env.RECUR_SECRET_KEY!,
});
export async function POST(request: NextRequest) {
const { customerId } = await request.json();
const session = await recur.portal.sessions.create({
customerId,
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
return NextResponse.json({ url: session.url });
}// Client Component
'use client';
export function ManageSubscriptionButton({ customerId }: { customerId: string }) {
const handleManage = async () => {
const response = await fetch('/api/portal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerId }),
});
const { url } = await response.json();
window.location.href = url;
};
return <button onClick={handleManage}>管理訂閱</button>;
}Webhook 處理
使用 Route Handler 接收 Webhook:
// app/api/webhooks/recur/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get('x-recur-signature');
// 驗證簽名
const expectedSignature = crypto
.createHmac('sha256', process.env.RECUR_WEBHOOK_SECRET!)
.update(body)
.digest('hex');
if (signature !== expectedSignature) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(body);
switch (event.type) {
case 'subscription.created':
// 處理新訂閱
console.log('新訂閱:', event.data.subscription.id);
break;
case 'subscription.canceled':
// 處理取消訂閱
console.log('訂閱取消:', event.data.subscription.id);
break;
case 'charge.succeeded':
// 處理付款成功
console.log('付款成功:', event.data.charge.id);
break;
}
return NextResponse.json({ received: true });
}查詢訂閱狀態
Server Component
// app/account/subscription/page.tsx
import { Recur } from 'recur-tw/server';
const recur = new Recur({
secretKey: process.env.RECUR_SECRET_KEY!,
});
export default async function SubscriptionPage({
searchParams,
}: {
searchParams: { customerId: string };
}) {
const { subscriptions } = await recur.subscriptions.list({
customerId: searchParams.customerId,
});
const activeSubscription = subscriptions.find((s) => s.status === 'ACTIVE');
if (!activeSubscription) {
return <p>目前沒有有效訂閱</p>;
}
return (
<div>
<h2>{activeSubscription.product.name}</h2>
<p>狀態:{activeSubscription.status}</p>
<p>下次扣款:{new Date(activeSubscription.currentPeriodEnd).toLocaleDateString()}</p>
</div>
);
}完整範例:定價頁面
// app/pricing/page.tsx
import { Recur } from 'recur-tw/server';
import { SubscribeButton } from '@/components/SubscribeButton';
const recur = new Recur({
secretKey: process.env.RECUR_SECRET_KEY!,
});
export default async function PricingPage() {
const { products } = await recur.products.list({ type: 'SUBSCRIPTION' });
return (
<div className="container mx-auto py-12">
<h1 className="text-3xl font-bold text-center mb-8">選擇你的方案</h1>
<div className="grid md:grid-cols-3 gap-6">
{products.map((product) => (
<div key={product.id} className="border rounded-lg p-6">
<h2 className="text-xl font-semibold">{product.name}</h2>
<p className="text-3xl font-bold mt-2">
NT$ {product.price}
<span className="text-sm font-normal">
/ {product.billingPeriod === 'MONTHLY' ? '月' : '年'}
</span>
</p>
<p className="text-gray-600 mt-2">{product.description}</p>
<SubscribeButton productId={product.id} className="mt-4 w-full" />
</div>
))}
</div>
</div>
);
}常見問題
Q: Server Component 和 Client Component 該怎麼選?
- Server Component:取得資料、不需要互動的 UI
- Client Component:需要使用 hooks、事件處理、瀏覽器 API
Q: 如何在 middleware 中驗證訂閱狀態?
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
// 從 session/cookie 取得用戶資訊
const customerId = request.cookies.get('customerId')?.value;
if (request.nextUrl.pathname.startsWith('/premium')) {
// 呼叫 API 檢查訂閱狀態
const response = await fetch(`${request.nextUrl.origin}/api/check-subscription`, {
method: 'POST',
body: JSON.stringify({ customerId }),
});
const { hasActiveSubscription } = await response.json();
if (!hasActiveSubscription) {
return NextResponse.redirect(new URL('/pricing', request.url));
}
}
return NextResponse.next();
}下一步
- Webhook 整合 - 深入了解事件處理
- Customer Portal - 完整的 Portal 功能
- API 參考 - 完整的 API 文件