Recur
開發者指南框架整合

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)、emailexternalId(你的系統 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>;
}

安全提醒useCustomercheck() 從本地快取讀取,可被繞過。僅用於 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 CheckoutEmbedded 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();
}

下一步

Last updated on

On this page