# Next.js 整合指南 (/guides/frameworks/nextjs)





本指南適用於 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
```

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

設定 Provider [#設定-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（推薦） [#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（進階） [#embedded-checkout進階]

使用 `checkout` 在你的頁面內嵌入付款表單（Modal 或 Iframe 模式）。

<Callout type="warn">
  **注意**：Embedded Checkout 需要 PAYUNi 驗證你的網域，**在 localhost 環境下無法使用**。開發階段請使用上方的 Hosted Checkout。
</Callout>

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

<Callout type="info">
  **付款錯誤處理**：`onPaymentFailed` 讓您可以根據失敗原因（如卡片被拒絕、餘額不足）自訂處理邏輯。詳見[錯誤處理指南](/guides/checkout/error-handling)。
</Callout>

取得產品列表 [#取得產品列表]

Client Component [#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-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 [#customer-portal]

方法 1：Server Action（推薦） [#方法-1server-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 [#方法-2route-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>;
}
```

<Callout type="info">
  Portal sessions 支援三種客戶識別方式：`customer`（Recur ID）、`email`、`externalId`（你的系統 User ID）。擇一即可。
</Callout>

Webhook 處理 [#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-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 [#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>;
}
```

<Callout type="warn">
  **安全提醒**：`useCustomer` 的 `check()` 從本地快取讀取，可被繞過。僅用於 UI 控制，敏感操作請在後端驗證。
</Callout>

完整範例：定價頁面 [#完整範例定價頁面]

```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 該怎麼選？ [#q-server-component-和-client-component-該怎麼選]

* **Server Component**：取得資料、不需要互動的 UI
* **Client Component**：需要使用 hooks、事件處理、瀏覽器 API

Q: Hosted Checkout 和 Embedded Checkout 的差異？ [#q-hosted-checkout-和-embedded-checkout-的差異]

|                  | Hosted Checkout        | Embedded Checkout    |
| ---------------- | ---------------------- | -------------------- |
| **整合方式**         | `redirectToCheckout()` | `checkout()`         |
| **付款頁面**         | checkout.recur.tw      | 你的網站（Modal / Iframe） |
| **localhost 開發** | ✅ 支援                   | ❌ 不支援（PAYUNi 網域限制）   |
| **適用場景**         | 大多數情況（推薦）              | 需要自訂付款體驗             |

Q: 如何在 middleware 中驗證訂閱狀態？ [#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) - 完整的 API 文件
