用 Next.js 和 Supabase 在 2026 年建立 MemberPress 替代方案
用 Next.js 和 Supabase 在 2026 年打造 MemberPress 替代方案
我在 MemberPress 上開發了兩次會員網站。兩次都在 18 個月內將其完全移除。不是因為 MemberPress 不好——它確實是更好的 WordPress 會員插件之一——而是一旦你需要自訂功能,你就會與插件對抗,而不是在開發你的產品。
第三次,我用 Next.js 和 Supabase 從頭開始構建整個系統。核心功能用了大約兩週的時間,最終結果比我用 WordPress 插件拼湊的任何東西都更快、更便宜且靈活無限。如果你在 2026 年評估 MemberPress 替代方案,讓我為你節省時間:你不需要另一個插件。你需要一個你可以控制的技術棧。
本文詳細介紹了如何構建一個生產級別的會員網站——身份驗證、基於角色的內容限制、Stripe 訂閱、會員儀表板和管理工具——而無需涉及 WordPress。
目錄
- 為什麼 MemberPress 在自訂項目上力不從心
- 2026 年採用自訂技術棧的理由
- 架構概覽:Next.js + Supabase + Stripe
- 為會員資料設定 Supabase
- 身份驗證和基於角色的存取
- 使用 Next.js 中間件進行內容限制
- Stripe 訂閱整合
- 構建會員儀表板
- 管理面板和分析
- 與 MemberPress 替代方案的比較
- 部署和成本
- 常見問題

為什麼 MemberPress 在自訂項目上力不從心
MemberPress 對於特定用例運作良好:你有一個 WordPress 網站,想要將一些內容隱藏在付費牆後面,並且不需要超出插件提供的內容之外的多少自訂。問題是大多數認真的會員業務很快就會超出這個框框。
以下是我遇到的情況:
效能。 MemberPress 網站上的每個頁面加載都會經過 WordPress 的 PHP 執行、會員檢查的資料庫查詢以及你堆積的任何其他插件。我的會員網站在共享主機上的 TTFB 達到 2-3 秒,即使在帶對象快取的 VPS 上,它也很少低於 800ms。
自訂上限。 MemberPress 提供了鉤子和篩選器,但如果你想要自訂的登入流程、具有使用分析的個人化儀表板,或根據成員進度動態調整的內容——你就在編寫與插件架構相衝突的自訂 PHP。
鎖定。 你的成員資料、內容規則和業務邏輯都存在於 WordPress 的資料庫架構中,與 MemberPress 的自訂表纏繞在一起。遷出並非微不足道。我做過。這是你不想擁有的一個週末。
規模成本。 MemberPress Plus 運行 $399/年(2026 年定價)。加上可以處理已驗證流量的高級 WordPress 主機、快取插件、安全插件和備份解決方案——在支付 Stripe 的交易費用之前,你的基礎設施輕鬆達到 $150-200/月。
這些都不意味著 MemberPress 不好。對於想要限制幾篇部落格文章且不想編寫程式碼的獨立工作者來說,它確實很好。但如果你正在構建一個會員網站作為核心產品——尤其是如果你的團隊中有開發人員——有更好的方法。
2026 年採用自訂技術棧的理由
工具景觀已發生巨大轉變。在 2022 年,構建自訂會員網站意味著連接一打服務並編寫數千行樣板代碼。在 2026 年,三個工具為你提供了 MemberPress 所做的一切以及更多:
- Next.js 15 帶有 App Router 處理渲染、路由、基於中間件的存取控制和 API 路由。
- Supabase 提供 Postgres 資料庫、身份驗證(包括魔術連結、OAuth 和電子郵件/密碼)、行級安全性和即時訂閱——所有這些都配有慷慨的免費層。
- Stripe 處理支付、訂閱、發票、客戶入口網站和稅務合規性。
為 5,000 名成員提供服務的會員網站的總基礎設施成本?約 $25-45/月。我們稍後會詳細介紹這些數字。
架構概覽:Next.js + Supabase + Stripe
以下是高級架構:
┌──────────────────────────────────────────────┐
│ Next.js 應用 │
│ ┌─────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 頁面 │ │中間件 │ │ API 路由 │ │
│ │(限制+ │ │(認證+ │ │(webhooks+ │ │
│ │ 公開) │ │ RBAC) │ │ admin APIs) │ │
│ └────┬─────┘ └────┬─────┘ └──────┬────────┘ │
│ │ │ │ │
└───────┼────────────┼──────────────┼────────────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌─────▼─────┐
│Supabase │ │Supabase │ │ Stripe │
│ DB │ │ 身份驗證 │ │ 計費 │
└─────────┘ └─────────┘ └───────────┘
流程很直接:
- 使用者註冊 → Supabase Auth 建立使用者
- 使用者訂閱 → Stripe Checkout 處理支付
- Stripe webhook → 更新 Supabase 中使用者的訂閱狀態
- 使用者訪問限制內容 → Next.js 中間件檢查 Supabase 中的角色/等級
- 內容根據會員等級呈現或重定向

為會員資料設定 Supabase
從資料庫架構開始。除了 Supabase Auth 開箱即用提供的功能外,你還需要三個核心表:
-- 擴展 Supabase auth.users 的檔案表
create table public.profiles (
id uuid references auth.users on delete cascade primary key,
email text not null,
full_name text,
avatar_url text,
membership_tier text default 'free' check (membership_tier in ('free', 'basic', 'pro', 'enterprise')),
stripe_customer_id text unique,
subscription_status text default 'inactive' check (subscription_status in ('active', 'inactive', 'past_due', 'canceled')),
subscription_id text,
current_period_end timestamptz,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- 限制資源的內容表
create table public.content (
id uuid default gen_random_uuid() primary key,
title text not null,
slug text unique not null,
body text,
content_type text default 'article' check (content_type in ('article', 'video', 'download', 'course')),
required_tier text default 'free' check (required_tier in ('free', 'basic', 'pro', 'enterprise')),
published boolean default false,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- 成員活動的審計日誌
create table public.member_activity (
id uuid default gen_random_uuid() primary key,
user_id uuid references public.profiles on delete cascade,
action text not null,
metadata jsonb default '{}',
created_at timestamptz default now()
);
現在啟用行級安全性。這是 Supabase 對會員網站真正閃耀的地方——資料庫本身強制執行存取規則:
alter table public.profiles enable row level security;
alter table public.content enable row level security;
-- 使用者可以讀取自己的檔案
create policy "使用者讀取自己的檔案" on public.profiles
for select using (auth.uid() = id);
-- 使用者可以更新自己的檔案(但不能更新 membership_tier 或訂閱字段)
create policy "使用者更新自己的檔案" on public.profiles
for update using (auth.uid() = id)
with check (auth.uid() = id);
-- 基於會員等級的內容可見性
create or replace function public.tier_rank(tier text)
returns int as $$
begin
return case tier
when 'free' then 0
when 'basic' then 1
when 'pro' then 2
when 'enterprise' then 3
else 0
end;
end;
$$ language plpgsql security definer;
create policy "成員看到達到或低於其等級的內容" on public.content
for select using (
published = true and (
required_tier = 'free'
or tier_rank(
(select membership_tier from public.profiles where id = auth.uid())
) >= tier_rank(required_tier)
)
);
設定一個觸發器,在使用者註冊時自動建立檔案:
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, email, full_name, avatar_url)
values (
new.id,
new.email,
new.raw_user_meta_data ->> 'full_name',
new.raw_user_meta_data ->> 'avatar_url'
);
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
身份驗證和基於角色的存取
Supabase 的 @supabase/ssr 套件在 Next.js App Router 中處理身份驗證。安裝它:
npm install @supabase/supabase-js @supabase/ssr
為伺服器元件建立 Supabase 客戶端:
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}
現在建立一個輔助函式來取得當前使用者的會員資料:
// lib/membership.ts
import { createClient } from './supabase/server'
export type MembershipTier = 'free' | 'basic' | 'pro' | 'enterprise'
const TIER_HIERARCHY: Record<MembershipTier, number> = {
free: 0,
basic: 1,
pro: 2,
enterprise: 3,
}
export async function getMemberProfile() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
return profile
}
export function hasAccess(userTier: MembershipTier, requiredTier: MembershipTier): boolean {
return TIER_HIERARCHY[userTier] >= TIER_HIERARCHY[requiredTier]
}
使用 Next.js 中間件進行內容限制
這是魔法發生的地方。Next.js 中間件在頁面呈現之前在邊緣執行,所以未授權的使用者永遠不會觸及你的伺服器元件:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createServerClient } from '@supabase/ssr'
const PROTECTED_PATHS = [
{ path: '/members', requiredTier: 'basic' },
{ path: '/pro-content', requiredTier: 'pro' },
{ path: '/enterprise', requiredTier: 'enterprise' },
]
const TIER_RANK: Record<string, number> = {
free: 0, basic: 1, pro: 2, enterprise: 3,
}
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value)
response.cookies.set(name, value, options)
})
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
const pathname = request.nextUrl.pathname
const protectedRoute = PROTECTED_PATHS.find(p => pathname.startsWith(p.path))
if (protectedRoute) {
if (!user) {
return NextResponse.redirect(new URL('/login?redirect=' + pathname, request.url))
}
const { data: profile } = await supabase
.from('profiles')
.select('membership_tier, subscription_status')
.eq('id', user.id)
.single()
const userTier = profile?.membership_tier || 'free'
const isActive = profile?.subscription_status === 'active'
if (!isActive || TIER_RANK[userTier] < TIER_RANK[protectedRoute.requiredTier]) {
return NextResponse.redirect(new URL('/upgrade?required=' + protectedRoute.requiredTier, request.url))
}
}
return response
}
export const config = {
matcher: ['/members/:path*', '/pro-content/:path*', '/enterprise/:path*'],
}
這種方法比 MemberPress 基於 PHP 的內容限制要快得多。中間件在 CDN 邊緣執行,因此無論使用者位於何處,延遲通常都在 50ms 以下。
Stripe 訂閱整合
在 Stripe 中建立你的訂閱產品,然後連接結帳流程。以下是 API 路由:
// app/api/checkout/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const PRICE_MAP: Record<string, string> = {
basic: process.env.STRIPE_BASIC_PRICE_ID!,
pro: process.env.STRIPE_PRO_PRICE_ID!,
enterprise: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
}
export async function POST(request: Request) {
const { tier } = await request.json()
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: '未授權' }, { status: 401 })
}
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id, email')
.eq('id', user.id)
.single()
let customerId = profile?.stripe_customer_id
if (!customerId) {
const customer = await stripe.customers.create({
email: profile?.email || user.email,
metadata: { supabase_user_id: user.id },
})
customerId = customer.id
await supabase
.from('profiles')
.update({ stripe_customer_id: customerId })
.eq('id', user.id)
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: PRICE_MAP[tier], quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/members/welcome?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: { supabase_user_id: user.id, tier },
})
return NextResponse.json({ url: session.url })
}
webhook 處理器是關鍵部分——當 Stripe 事件觸發時,它會更新你的 Supabase 資料庫:
// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // 管理員存取權限,繞過 RLS
)
export async function POST(request: Request) {
const body = await request.text()
const sig = request.headers.get('stripe-signature')!
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
)
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const subscription = await stripe.subscriptions.retrieve(session.subscription as string)
const userId = session.metadata?.supabase_user_id
const tier = session.metadata?.tier
await supabaseAdmin.from('profiles').update({
membership_tier: tier,
subscription_status: 'active',
subscription_id: subscription.id,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
}).eq('id', userId)
break
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const customerId = subscription.customer as string
const { data: profile } = await supabaseAdmin
.from('profiles')
.select('id')
.eq('stripe_customer_id', customerId)
.single()
if (profile) {
await supabaseAdmin.from('profiles').update({
subscription_status: subscription.status === 'active' ? 'active' : 'inactive',
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
}).eq('id', profile.id)
}
break
}
}
return NextResponse.json({ received: true })
}
構建會員儀表板
透過身份驗證和計費連線,會員儀表板是從 Supabase 讀取的標準伺服器元件:
// app/members/dashboard/page.tsx
import { getMemberProfile } from '@/lib/membership'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const profile = await getMemberProfile()
if (!profile) redirect('/login')
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<h1 className="text-3xl font-bold mb-8">歡迎回來,{profile.full_name}</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-sm text-gray-500 uppercase">你的計劃</h2>
<p className="text-2xl font-semibold capitalize mt-1">{profile.membership_tier}</p>
<p className="text-sm text-gray-500 mt-2">
更新於 {new Date(profile.current_period_end).toLocaleDateString()}
</p>
</div>
{/* 在此添加更多儀表板小組件 */}
</div>
</div>
)
}
你還可以授予成員存取 Stripe 客戶入口網站的權限,以進行自助計費管理——不需要自訂計費 UI:
// app/api/billing-portal/route.ts
const session = await stripe.billingPortal.sessions.create({
customer: profile.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/members/dashboard`,
})
管理面板和分析
對於管理儀表板,使用 Supabase 的服務角色金鑰(僅伺服器端)在所有使用者中進行查詢。你可以追蹤:
- 每天/每週/每月新註冊
- 按等級的流失率
- 收入(直接從 Stripe 的 API 提取)
- 內容參與度(使用
member_activity表)
這是自訂構建真正值得付出的地方。MemberPress 提供基本統計頁面。透過直接存取你的 Postgres 資料庫,你可以執行任何你想要的查詢。需要知道哪些文章帶動最多升級?加入你的活動日誌和檔案更新。使用 SQL 是微不足道的,而使用 MemberPress 則不可能,除非沒有第三方分析工具。
與 MemberPress 替代方案的比較
讓我們把這個放在人們在 2026 年評估的熱門替代方案的背景下:
| 功能 | MemberPress | Memberful | Paid Memberships Pro | 自訂(Next.js + Supabase) |
|---|---|---|---|---|
| 月成本 | ~$33/月(年度) | $49/月 + 4.9% 交易費 | 免費(基礎版)/ $347/年 | $25-45/月 主機 |
| 交易費用 | Stripe 標準 | 4.9% + Stripe | Stripe 標準 | 僅 Stripe 標準 |
| 自訂 UI | WordPress 主題 | 受限 | WordPress 主題 | 無限制 |
| 效能 (TTFB) | 500ms-2s+ | ~200ms(託管) | 500ms-2s+ | <100ms(邊緣) |
| 所需主機 | WordPress 主機 | 無(託管) | WordPress 主機 | Vercel/Netlify |
| 資料庫存取 | WP + 插件表 | 無直接存取 | WP + 插件表 | 完整 Postgres 存取 |
| 內容類型 | 文章、頁面、檔案 | 文章、播客 | 文章、頁面 | 你構建的任何東西 |
| API 存取 | 有限 REST | GraphQL API | 有限 REST | 完整 API 控制 |
| 廠商鎖定 | 高(WP + 插件) | 中等 | 高(WP + 插件) | 低(標準工具) |
| 設定時間 | 1-2 小時 | 30 分鐘 | 1-2 小時 | 1-2 週 |
| 最適合 | WP 內容限制 | 創作者、電子報 | WP 電子商務 | 自訂產品 |
權衡很清楚:如果你想要標準會員部落格,Memberful 或 MemberPress 可以更快地讓你運行。但自訂路線為你提供更好的效能、更低的持續成本(除 Stripe 的 2.9% + 30¢ 外沒有平台交易費),以及完全控制體驗。
如果你的團隊沒有熟悉 Next.js 的開發人員,這是在與專業 headless 開發機構合作的地方。我們在 Social Animal 已經在這個確切的堆棧上構建了幾個會員平台——這裡介紹的架構基本上是我們的起始範本。
部署和成本
以下是為 5,000 名活躍成員提供服務的會員網站的現實成本分析:
| 服務 | 等級 | 月成本 |
|---|---|---|
| Vercel(主機) | Pro | $20/月 |
| Supabase | Pro | $25/月 |
| Stripe | 隨用隨付 | 每筆交易 2.9% + 30¢ |
| 網域 + DNS | Cloudflare | 免費 |
| 電子郵件(交易) | Resend | $20/月 |
| 固定成本總計 | ~$65/月 |
將其與在優質 WordPress 主機(WP Engine 或 Kinsta,$30-115/月)上運行 MemberPress 進行比較,加上插件許可證($399/年),加上你需要的任何附加插件。自訂堆棧在價格上具有競爭力,效能上也大幅優勝。
使用 vercel --prod 部署到 Vercel。設定你的環境變數。配置 Stripe webhook 端點。你就上線了。
對於想要此架構但不想自己維護的團隊,我們的無頭 CMS 開發服務包括持續維護和功能開發。我們也可以將 Supabase 與 Sanity 或 Payload 等無頭 CMS 配對,以獲得內容管理層——如果你對靜態優先方法感興趣,詳情請見我們的功能頁面。
常見問題
用 Next.js 構建自訂會員網站比使用 MemberPress 更難嗎? 誠實地說,是的——起初。如果你是開發人員,期望花費約 1-2 週來構建核心功能:身份驗證、計費、內容限制和會員儀表板。MemberPress 花一個下午。區別在於啟動後發生的事情。使用 MemberPress,每個自訂功能都是一場戰鬥。使用自訂堆棧,你是在你完全理解和控制的基礎上進行構建。長期維護負擔實際上更低,因為你不是在管理 WordPress 更新、插件衝突以及十多個插件的安全補丁。
Supabase 可以像 Auth0 這樣的專用服務一樣處理身份驗證嗎? 對於會員網站,絕對可以。Supabase Auth 支援電子郵件/密碼、魔術連結、電話 OTP 和 OAuth 提供商(Google、GitHub、Apple 等),開箱即用。它建立在 GoTrue 上,這是 Netlify 使用的相同身份驗證服務。對於 99% 的會員網站,這綽綽有餘。你只有在具有企業 SSO 要求(如 SAML)或複雜多租戶設定時才需要 Auth0。
沒有 WordPress 我如何處理內容管理? 你有多個選擇。你可以直接將內容儲存在 Supabase 中(適合較小的網站),使用 Sanity、Payload 或 Contentful 等無頭 CMS 來實現編輯體驗,或者甚至在你的 repo 中使用 MDX 檔案來實現文件風格的會員網站。內容儲存完全與會員邏輯分離,這實際上是一個巨大的優勢。
滴漏內容和排定發佈如何處理?
將 published_at 時間戳和 drip_days_after_signup 列新增到你的內容表。在你的查詢中,將成員的 created_at 日期加上滴漏偏移量與當前日期比較。這是單個 WHERE 子句。MemberPress 有一個專用的滴漏功能,當然,但自訂版本為你提供了更大的靈活性——你可以根據課程進度、參與度指標或任何其他信號進行滴漏。
與 WordPress MemberPress 相比,此方法如何處理 SEO? 在大多數情況下更好。Next.js 生成具有完整元資料控制的伺服器呈現 HTML。你獲得更好的核心網路活力分數(直接影響 2026 年的排名)、完全的結構化資料控制,以及在對搜尋引擎隱藏完整版本的同時顯示預告內容的能力。MemberPress 經常完全阻止搜尋引擎索引內容,除非你小心配置。
我可以將現有 MemberPress 成員遷移到此堆棧嗎? 是的。從 MemberPress 匯出你的成員(電子郵件、名稱、訂閱等級、Stripe 客戶 ID)。編寫建立 Supabase Auth 使用者和檔案記錄的遷移腳本。由於大多數 MemberPress 網站使用 Stripe,你可以保留相同的 Stripe 客戶 ID 和訂閱——只需將 webhook 指向新的端點。Stripe 訂閱在沒有中斷的情況下繼續執行。
如果我需要社群功能,比如論壇或評論呢? Supabase 的即時訂閱使構建實時評論系統或討論論壇變得簡單。對於更豐富的功能,與 Discord 整合(基於會員等級限制伺服器存取)或嵌入 Hyvor Talk 之類的工具。重點是你選擇適合的社群工具,而不是被鎖定在 MemberPress 的附加生態系統提供的任何東西。
這種方法對非技術創始人合適嗎? 如果你不是開發人員,並且你的團隊中沒有開發人員,這可能不是正確的路徑。Memberful 是更好的選擇——它是託管的,需要最少的設定,並與大多數網站平台集成。但如果你有開發人員(或你願意聘用專門從事 headless 構建的機構),自訂方法將在你的會員業務增長時為你提供遠更好的服務。對於我們所做的大多數項目,前期投資在 6-12 個月內就能回本。