---
title: Next.js 整合指南
description: 在 Next.js App Router 中整合 Recur 訂閱功能
---

本指南適用於 Next.js 13+ 的 App Router。Recur 提供 React Hooks 和 Server SDK，讓你可以輕鬆整合訂閱功能。

## 安裝

```bash
npm install recur-tw
```

## 設定環境變數

在 `.env.local` 中加入：

```bash
# 前端使用（可公開）
NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY=pk_test_xxx

# 後端使用（保密）
RECUR_SECRET_KEY=sk_test_xxx
```

**安全提醒**：`RECUR_SECRET_KEY` 絕對不可在前端使用。只有 `NEXT_PUBLIC_` 開頭的變數才會被打包到前端。

## 設定 Provider

在 `app/providers.tsx` 中設定 RecurProvider：

```tsx
'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` 中使用：

```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 開發）。

```tsx
'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`：

```tsx
'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。

```tsx
'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` 讓您可以根據失敗原因（如卡片被拒絕、餘額不足）自訂處理邏輯。詳見[錯誤處理指南](/guides/checkout/error-handling)。

## 取得產品列表

### Client Component

使用 `useProducts` Hook 取得產品列表：

```tsx
'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 在伺服器端取得資料，更快、更安全：

```tsx
// 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（推薦）

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

```tsx
// 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

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

```tsx
// 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：

```tsx
// 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 路由中驗證客戶權限，確保安全：

```tsx
// 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 控制）：

```tsx
'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 控制，敏感操作請在後端驗證。

## 完整範例：定價頁面

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

```tsx
// 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 中驗證訂閱狀態？

```tsx
// 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 整合](/guides/webhooks) - 深入了解事件處理
- [Customer Portal](/guides/portal) - 完整的 Portal 功能
- [API 參考](/api-reference) - 完整的 API 文件
