Recur
Recur
Recur 文件中心
開發者指南
框架整合指南Vanilla JS 整合指南Next.js 整合指南Astro 整合指南
開發者指南框架整合

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 文件

Vanilla JS 整合指南

使用純 JavaScript 整合 Recur 訂閱結帳功能

Astro 整合指南

在 Astro 專案中整合 Recur 訂閱功能

On this page

安裝設定環境變數設定 Provider結帳功能使用 useRecur HookRedirect 模式取得產品列表Client ComponentServer Component(推薦)Customer Portal方法 1:Server Action(推薦)方法 2:Route HandlerWebhook 處理查詢訂閱狀態Server Component完整範例:定價頁面常見問題Q: Server Component 和 Client Component 該怎麼選?Q: 如何在 middleware 中驗證訂閱狀態?下一步