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>
);
}結帳功能
Hosted Checkout(推薦)
使用 redirectToCheckout 將使用者導向 Recur 託管的結帳頁面。這是最簡單的整合方式,支援所有環境(包含 localhost 開發)。
'use client';
import { useRecur } from 'recur-tw';
export function SubscribeButton({ productId }: { productId: string }) {
const { redirectToCheckout, isCheckingOut } = useRecur();
const handleSubscribe = async () => {
await redirectToCheckout({
productId,
customerEmail: 'user@example.com', // 可選:預填 Email
successUrl: `${window.location.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${window.location.origin}/pricing`,
});
};
return (
<button onClick={handleSubscribe} disabled={isCheckingOut}>
{isCheckingOut ? '處理中...' : '立即訂閱'}
</button>
);
}如果你需要更多控制(例如在新視窗開啟),可以使用 createCheckoutSession:
'use client';
import { useRecur } from 'recur-tw';
export function SubscribeButton({ productId }: { productId: string }) {
const { createCheckoutSession } = useRecur();
const handleSubscribe = async () => {
const session = await createCheckoutSession({
productId,
successUrl: `${window.location.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${window.location.origin}/pricing`,
});
// 自行控制跳轉,例如在新視窗開啟
window.open(session.url, '_blank');
};
return <button onClick={handleSubscribe}>立即訂閱</button>;
}Embedded Checkout(進階)
使用 checkout 在你的頁面內嵌入付款表單(Modal 或 Iframe 模式)。
注意:Embedded Checkout 需要 PAYUNi 驗證你的網域,在 localhost 環境下無法使用。開發階段請使用上方的 Hosted Checkout。
'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';
},
onPaymentFailed: (error) => {
console.log('付款失敗:', error.details?.failure_code);
},
onError: (error) => {
alert('錯誤:' + error.message);
},
});
};
return (
<button onClick={handleSubscribe} disabled={isCheckingOut}>
{isCheckingOut ? '處理中...' : '立即訂閱'}
</button>
);
}付款錯誤處理:onPaymentFailed 讓您可以根據失敗原因(如卡片被拒絕、餘額不足)自訂處理邏輯。詳見錯誤處理指南。
取得產品列表
Client Component
使用 useProducts Hook 取得產品列表:
'use client';
import { useProducts, useRecur } from 'recur-tw';
export function PricingTable() {
const { data: products, isLoading } = useProducts({ type: 'SUBSCRIPTION' });
const { redirectToCheckout } = useRecur();
if (isLoading) return <div>載入中...</div>;
return (
<div className="grid grid-cols-3 gap-4">
{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">NT$ {product.price}</p>
<button
onClick={() =>
redirectToCheckout({
productId: product.id,
successUrl: `${window.location.origin}/success`,
cancelUrl: `${window.location.origin}/pricing`,
})
}
>
訂閱
</button>
</div>
))}
</div>
);
}Server Component(推薦)
使用 Server SDK 在伺服器端取得資料,更快、更安全:
// app/pricing/page.tsx
import { Recur } from 'recur-tw/server';
import { SubscribeButton } from '@/components/SubscribeButton';
const recur = new Recur(process.env.RECUR_SECRET_KEY!);
export default async function PricingPage() {
// 使用 entitlements API 或直接呼叫 API 取得產品列表
// Server SDK 目前提供 portal、entitlements、webhooks、paymentLinks 功能
return (
<div className="container mx-auto py-12">
<h1 className="text-3xl font-bold text-center mb-8">選擇你的方案</h1>
{/* 使用 Client Component 搭配 useProducts 取得產品列表 */}
<PricingTable />
</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(process.env.RECUR_SECRET_KEY!);
export async function redirectToPortal(email: string) {
const session = await recur.portal.sessions.create({
email,
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, 'user@example.com')}>
<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(process.env.RECUR_SECRET_KEY!);
export async function POST(request: NextRequest) {
const { email } = await request.json();
const session = await recur.portal.sessions.create({
email,
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
return NextResponse.json({ url: session.url });
}// Client Component
'use client';
export function ManageSubscriptionButton({ email }: { email: string }) {
const handleManage = async () => {
const response = await fetch('/api/portal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const { url } = await response.json();
window.location.href = url;
};
return <button onClick={handleManage}>管理訂閱</button>;
}Portal sessions 支援三種客戶識別方式:customer(Recur ID)、email、externalId(你的系統 User ID)。擇一即可。
Webhook 處理
使用 Route Handler 接收 Webhook:
// app/api/webhooks/recur/route.ts
import { Recur } from 'recur-tw/server';
import { NextRequest, NextResponse } from 'next/server';
const recur = new Recur(process.env.RECUR_SECRET_KEY!);
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get('x-recur-signature');
// 驗證簽名
let event;
try {
event = recur.webhooks.verify(
body,
signature!,
process.env.RECUR_WEBHOOK_SECRET!,
);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
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-side(推薦)
使用 Server SDK 在 API 路由中驗證客戶權限,確保安全:
// app/api/premium/route.ts
import { Recur } from 'recur-tw/server';
import { NextRequest, NextResponse } from 'next/server';
const recur = new Recur(process.env.RECUR_SECRET_KEY!);
export async function GET(request: NextRequest) {
const email = request.headers.get('x-user-email');
const { allowed } = await recur.entitlements.check({
product: 'pro-plan',
customer: { email: email! },
});
if (!allowed) {
return NextResponse.json({ error: '請升級到 Pro 方案' }, { status: 403 });
}
return NextResponse.json({ data: '這是付費內容' });
}Client-side
使用 useCustomer Hook 在前端快速判斷權限(適用於 UI 控制):
'use client';
import { RecurProvider, useCustomer, useRecur } from 'recur-tw';
// Provider 需傳入 customer 識別資訊
function App() {
const user = useAuth(); // 你的認證系統
return (
<RecurProvider
config={{ publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY! }}
customer={{ email: user?.email }}
>
<PremiumFeature />
</RecurProvider>
);
}
function PremiumFeature() {
const { check, isLoading } = useCustomer();
const { redirectToCheckout } = useRecur();
if (isLoading) return <div>載入中...</div>;
const { allowed } = check({ product: 'pro-plan' });
if (!allowed) {
return (
<button
onClick={() =>
redirectToCheckout({
productSlug: 'pro-plan',
successUrl: `${window.location.origin}/success`,
cancelUrl: `${window.location.origin}/pricing`,
})
}
>
升級到 Pro
</button>
);
}
return <div>Premium 內容</div>;
}安全提醒:useCustomer 的 check() 從本地快取讀取,可被繞過。僅用於 UI 控制,敏感操作請在後端驗證。
完整範例:定價頁面
// app/pricing/page.tsx
import { PricingTable } from '@/components/PricingTable';
export default function PricingPage() {
return (
<div className="container mx-auto py-12">
<h1 className="text-3xl font-bold text-center mb-8">選擇你的方案</h1>
<PricingTable />
</div>
);
}// components/PricingTable.tsx
'use client';
import { useProducts, useRecur } from 'recur-tw';
export function PricingTable() {
const { data: products, isLoading } = useProducts({ type: 'SUBSCRIPTION' });
const { redirectToCheckout, isCheckingOut } = useRecur();
if (isLoading) return <div>載入中...</div>;
return (
<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>
<button
className="mt-4 w-full bg-blue-600 text-white py-2 rounded-lg"
disabled={isCheckingOut}
onClick={() =>
redirectToCheckout({
productId: product.id,
successUrl: `${window.location.origin}/success`,
cancelUrl: `${window.location.origin}/pricing`,
})
}
>
{isCheckingOut ? '處理中...' : '訂閱'}
</button>
</div>
))}
</div>
);
}常見問題
Q: Server Component 和 Client Component 該怎麼選?
- Server Component:取得資料、不需要互動的 UI
- Client Component:需要使用 hooks、事件處理、瀏覽器 API
Q: Hosted Checkout 和 Embedded Checkout 的差異?
| Hosted Checkout | Embedded Checkout | |
|---|---|---|
| 整合方式 | redirectToCheckout() | checkout() |
| 付款頁面 | checkout.recur.tw | 你的網站(Modal / Iframe) |
| localhost 開發 | ✅ 支援 | ❌ 不支援(PAYUNi 網域限制) |
| 適用場景 | 大多數情況(推薦) | 需要自訂付款體驗 |
Q: 如何在 middleware 中驗證訂閱狀態?
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
// 從 session/cookie 取得用戶資訊
const email = request.cookies.get('userEmail')?.value;
if (request.nextUrl.pathname.startsWith('/premium')) {
// 呼叫 API 檢查訂閱狀態
const response = await fetch(`${request.nextUrl.origin}/api/check-subscription`, {
method: 'POST',
body: JSON.stringify({ email }),
});
const { allowed } = await response.json();
if (!allowed) {
return NextResponse.redirect(new URL('/pricing', request.url));
}
}
return NextResponse.next();
}下一步
- Webhook 整合 - 深入了解事件處理
- Customer Portal - 完整的 Portal 功能
- API 參考 - 完整的 API 文件
Last updated on