Astro 整合指南
在 Astro 專案中整合 Recur 訂閱功能
Astro 是一個內容優先的現代框架,支援 Islands Architecture 和多種渲染模式。Recur 提供官方 Astro adapter,讓你只需幾行程式碼就能整合訂閱功能。
前置需求
Astro 預設是靜態網站生成(SSG),如果需要使用 Server SDK 功能(如 Customer Portal、Webhook),你需要啟用 Server 模式並安裝 adapter。
安裝
官方 Astro adapter 提供最簡潔的整合方式:
npm install @recur-tw/astro@recur-tw/astro 提供預封裝的 handler 函數,大幅減少 boilerplate 程式碼。
如果你需要更多自訂彈性,可以直接使用核心 SDK:
npm install recur-tw設定輸出模式
在 astro.config.mjs 中設定:
全伺服器模式,所有頁面和 API 都在伺服器執行:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel'; // 或其他 adapter
export default defineConfig({
output: 'server',
adapter: vercel(),
});如果想讓部分頁面預先渲染(例如首頁),在該頁面加入 export const prerender = true;
預設靜態,只有 API routes 需要伺服器執行:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
adapter: node({ mode: 'standalone' }),
});在需要伺服器執行的 API 檔案中加入:
export const prerender = false;純靜態網站,只能使用前端功能:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
// 不需要設定 output,預設就是 static
});靜態模式無法使用 Server SDK。如需 Customer Portal 或 Webhook,需要另外建立後端。
設定環境變數
在 .env 中加入:
# 前端使用(可公開)
PUBLIC_RECUR_PUBLISHABLE_KEY=pk_test_xxx
# 後端使用(保密)
RECUR_SECRET_KEY=sk_test_xxx
RECUR_WEBHOOK_SECRET=whsec_xxx結帳功能
方法 1:使用 Adapter(推薦)
建立 Checkout Endpoint
// src/pages/api/checkout.ts
import { Checkout } from '@recur-tw/astro';
export const prerender = false;
// 使用 process.env 確保在 Vercel 等平台上正確讀取環境變數
const secretKey = process.env.RECUR_SECRET_KEY || import.meta.env.RECUR_SECRET_KEY;
export const GET = Checkout({
secretKey,
successUrl: '/success?session_id={CHECKOUT_SESSION_ID}',
cancelUrl: '/pricing',
locale: 'zh-TW',
});在頁面中使用
---
// src/pages/pricing.astro
---
<html>
<body>
<h1>選擇你的方案</h1>
<div class="email-input">
<label for="email">您的 Email</label>
<input type="email" id="email" placeholder="you@example.com" />
</div>
<button
data-product-id="prod_xxx"
onclick="checkout(this)"
>
立即訂閱
</button>
<script is:inline>
function checkout(btn) {
const email = document.getElementById('email').value;
if (!email) {
alert('請輸入 Email');
return;
}
const productId = btn.dataset.productId;
window.location.href = `/api/checkout?productId=${productId}&customerEmail=${encodeURIComponent(email)}`;
}
</script>
</body>
</html>customerEmail 是必填參數。如果沒有提供,API 會返回錯誤。
支援的 Query Parameters:
| 參數 | 說明 | 必填 |
|---|---|---|
productId | 產品 ID | ✓* |
productSlug | 產品 slug(替代 productId) | ✓* |
customerEmail | 客戶 Email | ✓ |
customerName | 客戶姓名 | |
customerId | 現有客戶 ID | |
metadata | URL-encoded JSON 字串 |
productId 或 productSlug 至少需提供一個
方法 2:自訂 Resolver
如果你需要從認證系統取得客戶資訊:
// src/pages/api/checkout.ts
import { Checkout } from '@recur-tw/astro';
import { getSession } from '@/lib/auth';
export const prerender = false;
export const GET = Checkout({
secretKey: import.meta.env.RECUR_SECRET_KEY,
successUrl: '/success',
resolver: async (context) => {
const session = await getSession(context.request);
return {
customerEmail: session?.user?.email,
customerName: session?.user?.name,
customerId: session?.user?.recurCustomerId,
};
},
});方法 3:純 Astro(無 Adapter)
使用 <script> 標籤直接引入 SDK:
---
// src/pages/pricing.astro
---
<html>
<head>
<title>定價方案</title>
</head>
<body>
<h1>選擇你的方案</h1>
<button id="subscribe-btn" data-product-id="prod_xxx">
立即訂閱
</button>
<script>
import Recur from 'recur-tw';
const recur = Recur.init({
publishableKey: import.meta.env.PUBLIC_RECUR_PUBLISHABLE_KEY,
});
document.getElementById('subscribe-btn')?.addEventListener('click', async (e) => {
const productId = (e.target as HTMLElement).dataset.productId;
await recur.checkout({
productId,
customerEmail: 'user@example.com',
customerName: '王小明',
mode: 'modal',
onPaymentComplete: (result) => {
alert('訂閱成功!');
window.location.href = '/success';
},
onError: (error) => {
alert('錯誤:' + error.message);
},
});
});
</script>
</body>
</html>Customer Portal
方法 1:使用 Adapter(推薦)
// src/pages/api/portal.ts
import { CustomerPortal } from '@recur-tw/astro';
export const prerender = false;
const secretKey = process.env.RECUR_SECRET_KEY || import.meta.env.RECUR_SECRET_KEY;
export const GET = CustomerPortal({
secretKey,
returnUrl: '/account',
});使用方式:/api/portal?customerId=cus_xxx
整合認證系統
// src/pages/api/portal.ts
import { CustomerPortal } from '@recur-tw/astro';
import { getSession } from '@/lib/auth';
export const prerender = false;
const secretKey = process.env.RECUR_SECRET_KEY || import.meta.env.RECUR_SECRET_KEY;
export const GET = CustomerPortal({
secretKey,
returnUrl: '/account',
resolver: async (context) => {
const session = await getSession(context.request);
if (!session?.user?.recurCustomerId) {
throw new Error('Unauthorized');
}
return { customerId: session.user.recurCustomerId };
},
});方法 2:手動實作
---
// src/pages/portal.astro
import { Recur } from 'recur-tw/server';
export const prerender = false;
const recur = new Recur(import.meta.env.RECUR_SECRET_KEY);
const customerId = Astro.url.searchParams.get('customerId');
if (!customerId) {
return Astro.redirect('/login');
}
const session = await recur.portal.sessions.create({
customer: customerId,
returnUrl: new URL('/account', Astro.url).toString(),
});
return Astro.redirect(session.url);
---查詢訂閱狀態
查詢已登入使用者的訂閱狀態是常見需求,例如:判斷是否顯示付費功能、控制存取權限等。
選擇適合的方法
根據你的需求選擇最適合的整合方式:
| 使用情境 | 推薦方法 | 說明 |
|---|---|---|
| 只有少數頁面需要檢查 | RecurClient | 直接在需要的頁面呼叫 API,簡單直覺 |
| 多個頁面都需要訂閱狀態 | withSubscription Middleware | 自動注入到 Astro.locals,避免重複程式碼 |
| 整個區塊需要付費才能存取 | requireSubscription Middleware | 自動重導向未訂閱的使用者 |
| 已有自己的權限/快取機制 | RecurClient | 完全掌控查詢時機和快取策略 |
| 需要跨 middleware 共享資料 | withSubscription + sequence | 與你現有的 auth middleware 組合 |
如果你已經有自己的 middleware:不一定需要使用我們的 middleware。你可以在現有的 middleware 或頁面邏輯中直接使用 RecurClient 查詢訂閱狀態,這樣可以更好地整合你現有的快取、session 或權限管理機制。
方法 1:使用 RecurClient(最彈性)
在任何伺服器端程式碼中直接查詢:
// src/pages/api/check-subscription.ts
import { RecurClient } from '@recur-tw/astro';
import { getSession } from '@/lib/auth';
export const prerender = false;
const recur = new RecurClient({
secretKey: import.meta.env.RECUR_SECRET_KEY,
});
export async function GET({ request }) {
const session = await getSession(request);
if (!session?.user) {
return new Response('Unauthorized', { status: 401 });
}
// 使用 email 查詢
const result = await recur.getSubscription({
email: session.user.email,
});
// 或使用你系統的 user ID(需先在建立顧客時設定 externalId)
// const result = await recur.getSubscription({
// externalId: session.user.id,
// });
return new Response(JSON.stringify({
hasAccess: result.hasActiveSubscription,
plan: result.subscriptions[0]?.planName,
}), {
headers: { 'Content-Type': 'application/json' },
});
}在 Astro 頁面中使用:
---
// src/pages/dashboard.astro
import { RecurClient } from '@recur-tw/astro';
import { getSession } from '@/lib/auth';
export const prerender = false;
const session = await getSession(Astro.request);
if (!session?.user) {
return Astro.redirect('/login');
}
const recur = new RecurClient({
secretKey: import.meta.env.RECUR_SECRET_KEY,
});
const { hasActiveSubscription, subscriptions } = await recur.getSubscription({
email: session.user.email,
});
if (!hasActiveSubscription) {
return Astro.redirect('/pricing');
}
const currentPlan = subscriptions[0];
---
<h1>歡迎回來!</h1>
<p>你的方案:{currentPlan.planName}</p>
<p>到期日:{new Date(currentPlan.currentPeriodEnd).toLocaleDateString('zh-TW')}</p>RecurClient 方法
| 方法 | 說明 | 回傳值 |
|---|---|---|
getSubscription(options) | 取得完整訂閱資訊 | SubscriptionResponse |
hasActiveSubscription(options) | 快速檢查是否有有效訂閱 | boolean |
getActiveSubscription(options) | 取得第一個有效訂閱 | SubscriptionInfo | null |
查詢參數(至少需提供一個):
| 參數 | 說明 |
|---|---|
email | 顧客 Email |
externalId | 你系統中的 User ID |
customerId | Recur 的 Customer ID |
planSlug | 篩選特定方案 |
planId | 篩選特定方案 ID |
activeOnly | 只查詢有效訂閱(預設 false) |
方法 2:使用 withSubscription Middleware
適合多個頁面都需要存取訂閱狀態的情境。Middleware 會在每個請求自動查詢並注入到 Astro.locals:
// src/middleware.ts
import { withSubscription } from '@recur-tw/astro';
import { getSession } from './lib/auth';
export const onRequest = withSubscription({
secretKey: import.meta.env.RECUR_SECRET_KEY,
getCustomer: async (context) => {
const session = await getSession(context.request);
if (!session?.user) return null;
// 使用你系統的 user ID
return { externalId: session.user.id };
// 或使用 email
// return { email: session.user.email };
},
});然後在任何頁面中直接使用:
---
// src/pages/dashboard.astro
const { subscription } = Astro.locals;
if (!subscription?.hasActiveSubscription) {
return Astro.redirect('/pricing');
}
const plan = subscription.subscriptions[0];
---
<h1>Dashboard</h1>
<p>方案:{plan.planName}</p>TypeScript 支援
為了取得 Astro.locals.subscription 的型別提示,在 src/env.d.ts 加入:
/// <reference types="astro/client" />
import type { SubscriptionLocals } from '@recur-tw/astro';
declare namespace App {
interface Locals extends SubscriptionLocals {}
}方法 3:使用 requireSubscription Middleware
適合需要整個區塊都限制為付費會員存取的情境,自動重導向未訂閱的使用者:
// src/middleware.ts
import { requireSubscription } from '@recur-tw/astro';
import { getSession } from './lib/auth';
export const onRequest = requireSubscription({
secretKey: import.meta.env.RECUR_SECRET_KEY,
getCustomer: async (context) => {
const session = await getSession(context.request);
if (!session?.user) return null;
return { externalId: session.user.id };
},
// 無訂閱時重導向到這裡
redirectTo: '/pricing',
// 排除這些路徑(公開頁面)
exclude: [
'/',
'/pricing',
'/login',
'/signup',
'/api/*',
'/public/**',
],
});在現有 Middleware 中整合 RecurClient
如果你已經有自己的 middleware 處理認證和權限,可以直接在裡面使用 RecurClient:
// src/middleware.ts
import type { MiddlewareHandler } from 'astro';
import { RecurClient } from '@recur-tw/astro';
import { getSession } from './lib/auth';
const recur = new RecurClient({
secretKey: import.meta.env.RECUR_SECRET_KEY,
});
export const onRequest: MiddlewareHandler = async (context, next) => {
// 你現有的認證邏輯
const session = await getSession(context.request);
context.locals.user = session?.user || null;
// 只有登入的使用者才查詢訂閱
if (context.locals.user) {
try {
// 你可以在這裡加入快取邏輯
const result = await recur.getSubscription({
externalId: context.locals.user.id,
});
context.locals.subscription = result;
context.locals.isPremium = result.hasActiveSubscription;
} catch (error) {
console.error('Failed to fetch subscription:', error);
context.locals.subscription = null;
context.locals.isPremium = false;
}
}
return next();
};這個方式讓你完全掌控查詢時機、錯誤處理和快取策略。例如你可以將結果存入 Redis 快取,避免每個請求都呼叫 API。
與多個 Middleware 組合(使用 sequence)
如果你選擇使用我們的 middleware,可以用 sequence 與其他 middleware 組合:
// src/middleware.ts
import { sequence } from 'astro:middleware';
import { withSubscription } from '@recur-tw/astro';
// 你的認證 middleware
const auth = async (context, next) => {
const session = await getSession(context.request);
context.locals.user = session?.user || null;
return next();
};
// 訂閱狀態 middleware
const subscription = withSubscription({
secretKey: import.meta.env.RECUR_SECRET_KEY,
getCustomer: (context) => {
// 從前一個 middleware 取得 user
if (!context.locals.user) return null;
return { externalId: context.locals.user.id };
},
});
export const onRequest = sequence(auth, subscription);Webhook 處理
使用 Adapter(推薦)
// src/pages/api/webhooks/recur.ts
import { Webhooks } from '@recur-tw/astro';
export const prerender = false;
const webhookSecret = process.env.RECUR_WEBHOOK_SECRET || import.meta.env.RECUR_WEBHOOK_SECRET;
export const POST = Webhooks({
webhookSecret,
// 通用 handler(可選)
onPayload: async (event) => {
console.log('收到事件:', event.type);
},
// 訂閱事件
onSubscriptionCreated: async (subscription) => {
console.log('新訂閱:', subscription.id);
// 發送歡迎郵件、開通權限等
},
onSubscriptionCanceled: async (subscription) => {
console.log('訂閱取消:', subscription.id);
// 關閉權限、發送挽留郵件等
},
// 付款事件
onChargeSucceeded: async (charge) => {
console.log('付款成功:', charge.id, charge.amount);
},
onChargeFailed: async (charge) => {
console.log('付款失敗:', charge.id, charge.failureMessage);
},
// 客戶事件
onCustomerCreated: async (customer) => {
console.log('新客戶:', customer.id, customer.email);
},
});支援的事件類型
| 事件 | Handler | 說明 |
|---|---|---|
subscription.created | onSubscriptionCreated | 新訂閱建立 |
subscription.updated | onSubscriptionUpdated | 訂閱更新 |
subscription.canceled | onSubscriptionCanceled | 訂閱取消 |
subscription.expired | onSubscriptionExpired | 訂閱過期 |
subscription.paused | onSubscriptionPaused | 訂閱暫停 |
subscription.resumed | onSubscriptionResumed | 訂閱恢復 |
charge.succeeded | onChargeSucceeded | 付款成功 |
charge.failed | onChargeFailed | 付款失敗 |
charge.refunded | onChargeRefunded | 已退款 |
customer.created | onCustomerCreated | 客戶建立 |
customer.updated | onCustomerUpdated | 客戶更新 |
customer.deleted | onCustomerDeleted | 客戶刪除 |
invoice.created | onInvoiceCreated | 發票建立 |
invoice.paid | onInvoicePaid | 發票已付 |
invoice.payment_failed | onInvoicePaymentFailed | 發票付款失敗 |
取得產品列表
在伺服器端取得(推薦)
---
// src/pages/pricing.astro
import { Recur } from 'recur-tw/server';
const recur = new Recur(import.meta.env.RECUR_SECRET_KEY);
const { products } = await recur.products.list({ type: 'SUBSCRIPTION' });
---
<html>
<body>
<h1>選擇你的方案</h1>
<div class="grid">
{products.map((product) => (
<div class="card">
<h2>{product.name}</h2>
<p class="price">
NT$ {product.price} / {product.billingPeriod === 'MONTHLY' ? '月' : '年'}
</p>
<p>{product.description}</p>
<a href={`/api/checkout?productId=${product.id}`}>
立即訂閱
</a>
</div>
))}
</div>
</body>
</html>React Island
如果你偏好 React 組件:
npx astro add react// src/components/SubscribeButton.tsx
import { useState } from 'react';
import Recur from 'recur-tw';
export function SubscribeButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
const handleSubscribe = async () => {
setLoading(true);
const recur = Recur.init({
publishableKey: import.meta.env.PUBLIC_RECUR_PUBLISHABLE_KEY,
});
await recur.checkout({
productId,
mode: 'modal',
onPaymentComplete: () => {
window.location.href = '/success';
},
onError: (error) => {
alert(error.message);
setLoading(false);
},
});
};
return (
<button onClick={handleSubscribe} disabled={loading}>
{loading ? '處理中...' : '立即訂閱'}
</button>
);
}在 Astro 頁面中使用(注意 client:load 指令):
---
import { SubscribeButton } from '../components/SubscribeButton';
---
<SubscribeButton productId="prod_xxx" client:load />client:load 讓組件在頁面載入後立即 hydrate。其他選項包括 client:visible(進入視窗時)和 client:idle(瀏覽器閒置時)。
輸出模式比較
| 功能 | Static | Server |
|---|---|---|
| 結帳(Modal/Redirect) | ✅ | ✅ |
| Checkout Adapter | ❌ | ✅ |
| Customer Portal Adapter | ❌ | ✅ |
| Webhooks Adapter | ❌ | ✅ |
| 取得產品列表(前端) | ✅ | ✅ |
| 取得產品列表(伺服器) | ❌ | ✅ |
建議:大多數情況使用 Server 模式搭配 prerender = true 來靜態化特定頁面。這讓你享受靜態頁面的效能,同時保留 API routes 的彈性。
常見問題
Q: 為什麼我的 API endpoint 返回 404?
確保你已經:
- 設定了 adapter(如
@astrojs/vercel) - 在
astro.config.mjs設定output: 'server' - 或在使用 static output 時,API 檔案中加入
export const prerender = false;
Q: React 組件為什麼沒有互動功能?
確保你加了 client:* 指令:
<!-- 錯誤:組件不會 hydrate -->
<SubscribeButton productId="prod_xxx" />
<!-- 正確:組件會在載入後 hydrate -->
<SubscribeButton productId="prod_xxx" client:load />Q: 如何在靜態模式下使用 Customer Portal?
你需要自行建立後端(如 Cloudflare Workers、Vercel Functions),或使用第三方服務。Recur 的 Portal 連結需要 Secret Key,無法在純前端生成。
部署到 Vercel
設定環境變數
在 Vercel Dashboard → Settings → Environment Variables 加入:
RECUR_SECRET_KEY=sk_live_xxx
RECUR_WEBHOOK_SECRET=whsec_xxx
PUBLIC_RECUR_PUBLISHABLE_KEY=pk_live_xxx部署
# 使用 Vercel CLI
vercel
# 或連結 GitHub 自動部署Vercel 會自動偵測 Astro 專案並正確設定。如果使用 output: 'server',Vercel 會自動建立 Serverless Functions。
下一步
- Webhook 整合 - 深入了解事件處理
- Customer Portal - 完整的 Portal 功能
- API 參考 - 完整的 API 文件