Build a MemberPress Alternative with Next.js and Supabase in 2026
I've built membership sites on MemberPress. Twice. Both times, I ended up ripping it out within 18 months. Not because MemberPress is bad -- it's genuinely one of the better WordPress membership plugins -- but because the moment you need something custom, you're fighting the plugin instead of building your product.
The third time around, I built the whole thing from scratch with Next.js and Supabase. It took about two weeks for the core functionality, and the result was faster, cheaper to run, and infinitely more flexible than anything I'd cobbled together with WordPress plugins. If you're weighing MemberPress alternatives in 2026, let me save you some time: you don't need another plugin. You need a stack you control.
This article walks through exactly how to build a production-ready membership site -- authentication, role-based content gating, Stripe subscriptions, member dashboards, and admin tooling -- without touching WordPress.
Table of Contents
- Why MemberPress Falls Short for Custom Projects
- The Case for a Custom Stack in 2026
- Architecture Overview: Next.js + Supabase + Stripe
- Setting Up Supabase for Membership Data
- Authentication and Role-Based Access
- Content Gating with Next.js Middleware
- Stripe Integration for Subscriptions
- Building the Member Dashboard
- Admin Panel and Analytics
- How This Compares to MemberPress Alternatives
- Deployment and Costs
- FAQ

Why MemberPress Falls Short for Custom Projects
MemberPress works well for a specific use case: you have a WordPress site, you want to gate some content behind a paywall, and you don't need much customization beyond what the plugin offers. The problem is that most serious membership businesses outgrow that box quickly.
Here's what I ran into:
Performance. Every page load on a MemberPress site runs through WordPress's PHP execution, database queries for membership checks, and whatever other plugins you've got stacked up. My membership site was hitting 2-3 second TTFB on shared hosting, and even on a VPS with object caching, it rarely dipped below 800ms.
Customization ceiling. MemberPress gives you hooks and filters, but if you want a custom onboarding flow, a personalized dashboard with usage analytics, or dynamic content that adapts to a member's progress -- you're writing custom PHP that fights the plugin architecture.
Lock-in. Your member data, content rules, and business logic all live inside WordPress's database schema, tangled with MemberPress's custom tables. Migrating off isn't trivial. I've done it. It's a weekend you don't want to have.
Cost at scale. MemberPress Plus runs $399/year (2026 pricing). Add in premium WordPress hosting that can handle authenticated traffic, caching plugins, security plugins, and backup solutions -- you're easily at $150-200/month for infrastructure before you've paid for Stripe's transaction fees.
None of this means MemberPress is bad. For a solopreneur who wants to gate a few blog posts and doesn't want to write code, it's genuinely fine. But if you're building a membership site as a core product -- especially if you have a developer on the team -- there's a better way.
The Case for a Custom Stack in 2026
The tooling landscape has shifted dramatically. In 2022, building a custom membership site meant wiring together a dozen services and writing thousands of lines of boilerplate. In 2026, three tools give you everything MemberPress does and more:
- Next.js 15 with the App Router handles rendering, routing, middleware-based access control, and API routes.
- Supabase gives you a Postgres database, authentication (including magic links, OAuth, and email/password), Row Level Security, and real-time subscriptions -- all with a generous free tier.
- Stripe handles payments, subscriptions, invoicing, customer portals, and tax compliance.
The total infrastructure cost for a membership site serving 5,000 members? About $25-45/month. We'll break down the numbers later.
Architecture Overview: Next.js + Supabase + Stripe
Here's the high-level architecture:
┌──────────────────────────────────────────────┐
│ Next.js Application │
│ ┌─────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ Pages │ │Middleware│ │ API Routes │ │
│ │(gated + │ │(auth + │ │(webhooks + │ │
│ │ public) │ │ RBAC) │ │ admin APIs) │ │
│ └────┬─────┘ └────┬─────┘ └──────┬────────┘ │
│ │ │ │ │
└───────┼────────────┼──────────────┼────────────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌─────▼─────┐
│Supabase │ │Supabase │ │ Stripe │
│ DB │ │ Auth │ │ Billing │
└─────────┘ └─────────┘ └───────────┘
The flow is straightforward:
- User signs up → Supabase Auth creates the user
- User subscribes → Stripe Checkout handles payment
- Stripe webhook → Updates the user's subscription status in Supabase
- User visits gated content → Next.js middleware checks their role/tier in Supabase
- Content renders or redirects based on membership level

Setting Up Supabase for Membership Data
Start with the database schema. You need three core tables beyond what Supabase Auth gives you out of the box:
-- Profiles table extending 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()
);
-- Content table for gated resources
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()
);
-- Audit log for member activity
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()
);
Now enable Row Level Security. This is where Supabase really shines for membership sites -- the database itself enforces access rules:
alter table public.profiles enable row level security;
alter table public.content enable row level security;
-- Users can read their own profile
create policy "Users read own profile" on public.profiles
for select using (auth.uid() = id);
-- Users can update their own profile (but not membership_tier or subscription fields)
create policy "Users update own profile" on public.profiles
for update using (auth.uid() = id)
with check (auth.uid() = id);
-- Content visibility based on membership tier
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 "Members see content at or below their tier" 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)
)
);
Set up a trigger to automatically create a profile when a user signs up:
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();
Authentication and Role-Based Access
Supabase's @supabase/ssr package handles auth in Next.js App Router. Install it:
npm install @supabase/supabase-js @supabase/ssr
Create a Supabase client for server components:
// 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)
)
},
},
}
)
}
Now create a helper to fetch the current user's membership data:
// 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]
}
Content Gating with Next.js Middleware
This is where the magic happens. Next.js middleware runs at the edge before the page renders, so unauthorized users never even hit your server components:
// 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*'],
}
This approach is significantly faster than MemberPress's PHP-based content restriction. The middleware runs at the CDN edge, so the latency is typically under 50ms regardless of where your user is located.
Stripe Integration for Subscriptions
Create your subscription products in Stripe, then wire up a checkout flow. Here's the API route:
// 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: 'Unauthorized' }, { 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 })
}
The webhook handler is the critical piece -- this is what updates your Supabase database when Stripe events fire:
// 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! // Admin access, bypasses 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 })
}
Building the Member Dashboard
With auth and billing wired up, the member dashboard is a standard server component that reads from 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">Welcome back, {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">Your Plan</h2>
<p className="text-2xl font-semibold capitalize mt-1">{profile.membership_tier}</p>
<p className="text-sm text-gray-500 mt-2">
Renews {new Date(profile.current_period_end).toLocaleDateString()}
</p>
</div>
{/* Add more dashboard widgets here */}
</div>
</div>
)
}
You can also give members access to the Stripe Customer Portal for self-service billing management -- no custom billing UI needed:
// 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`,
})
Admin Panel and Analytics
For an admin dashboard, use Supabase's service role key (server-side only) to query across all users. You can track things like:
- New signups per day/week/month
- Churn rate by tier
- Revenue (pull from Stripe's API directly)
- Content engagement (using the
member_activitytable)
This is where a custom build really pays off. MemberPress gives you a basic stats page. With direct access to your Postgres database, you can run any query you want. Need to know which articles drive the most upgrades? Join your activity log with your profile updates. That kind of analysis is trivial with SQL and impossible with MemberPress without third-party analytics tools.
How This Compares to MemberPress Alternatives
Let's put this in context with the popular alternatives people are evaluating in 2026:
| Feature | MemberPress | Memberful | Paid Memberships Pro | Custom (Next.js + Supabase) |
|---|---|---|---|---|
| Monthly Cost | ~$33/mo (annual) | $49/mo + 4.9% tx fee | Free (basic) / $347/yr | $25-45/mo hosting |
| Transaction Fees | Stripe standard | 4.9% + Stripe | Stripe standard | Stripe standard only |
| Custom UI | WordPress themes | Limited | WordPress themes | Unlimited |
| Performance (TTFB) | 500ms-2s+ | ~200ms (hosted) | 500ms-2s+ | <100ms (edge) |
| Hosting Required | WordPress hosting | None (hosted) | WordPress hosting | Vercel/Netlify |
| Database Access | WP + plugin tables | No direct access | WP + plugin tables | Full Postgres access |
| Content Types | Posts, pages, files | Articles, podcasts | Posts, pages | Anything you build |
| API Access | Limited REST | GraphQL API | Limited REST | Full API control |
| Vendor Lock-in | High (WP + plugin) | Medium | High (WP + plugin) | Low (standard tools) |
| Setup Time | 1-2 hours | 30 minutes | 1-2 hours | 1-2 weeks |
| Best For | WP content gating | Creators, newsletters | WP e-commerce | Custom products |
The tradeoff is clear: Memberful or MemberPress get you running faster if you want a standard membership blog. But the custom route gives you better performance, lower ongoing costs (no platform transaction fees beyond Stripe's 2.9% + 30¢), and complete control over the experience.
If your team doesn't have a developer comfortable with Next.js, this is where working with a headless development agency makes sense. We've built several membership platforms on this exact stack at Social Animal -- the architecture described here is essentially our starting template.
Deployment and Costs
Here's a realistic cost breakdown for a membership site serving 5,000 active members:
| Service | Tier | Monthly Cost |
|---|---|---|
| Vercel (hosting) | Pro | $20/mo |
| Supabase | Pro | $25/mo |
| Stripe | Pay-as-you-go | 2.9% + 30¢ per transaction |
| Domain + DNS | Cloudflare | Free |
| Email (transactional) | Resend | $20/mo |
| Total fixed costs | ~$65/mo |
Compare that to running MemberPress on quality WordPress hosting (WP Engine or Kinsta at ~$30-115/mo), plus the plugin license ($399/yr), plus whatever add-on plugins you need. The custom stack is competitive on price and dramatically better on performance.
Deploy to Vercel with vercel --prod. Set your environment variables. Configure the Stripe webhook endpoint. You're live.
For teams that want this architecture but don't want to maintain it themselves, our headless CMS development service includes ongoing maintenance and feature development. We can also pair Supabase with a headless CMS like Sanity or Payload for the content management layer -- details on our capabilities page if you're curious about static-first approaches.
FAQ
Is building a custom membership site with Next.js harder than using MemberPress?
Honestly, yes -- initially. If you're a developer, expect about 1-2 weeks to build the core: auth, billing, content gating, and a member dashboard. MemberPress takes an afternoon. The difference is what happens after launch. With MemberPress, every custom feature is a fight. With a custom stack, you're building on a foundation you fully understand and control. The long-term maintenance burden is actually lower because you're not managing WordPress updates, plugin conflicts, and security patches for a dozen plugins.
Can Supabase handle authentication as well as a dedicated service like Auth0?
For membership sites, absolutely. Supabase Auth supports email/password, magic links, phone OTP, and OAuth providers (Google, GitHub, Apple, etc.) out of the box. It's built on GoTrue, the same auth service Netlify uses. For 99% of membership sites, it's more than sufficient. You'd only need Auth0 if you have enterprise SSO requirements like SAML or complex multi-tenant setups.
How do I handle content management without WordPress?
You have several options. You can store content directly in Supabase (fine for smaller sites), use a headless CMS like Sanity, Payload, or Contentful for the editorial experience, or even use MDX files in your repo for a docs-style membership site. The content storage is completely decoupled from the membership logic, which is actually a huge advantage.
What about drip content and scheduled releases?
Add a published_at timestamp and a drip_days_after_signup column to your content table. In your query, compare the member's created_at date plus the drip offset against the current date. It's a single WHERE clause. MemberPress has a dedicated drip feature, sure, but the custom version gives you far more flexibility -- you could drip based on course progress, engagement metrics, or any other signal.
How does this approach handle SEO compared to WordPress with MemberPress?
Better, in most cases. Next.js generates server-rendered HTML with full metadata control. You get better Core Web Vitals scores (which directly affect rankings in 2026), full control over structured data, and the ability to show teaser content to search engines while gating the full version. MemberPress often blocks content from crawlers entirely unless you configure it carefully.
Can I migrate my existing MemberPress members to this stack?
Yes. Export your members from MemberPress (email, name, subscription tier, Stripe customer ID). Write a migration script that creates Supabase Auth users and profile records. Since most MemberPress sites use Stripe, you can keep the same Stripe customer IDs and subscriptions -- just point the webhooks to your new endpoint. The Stripe subscriptions keep running without interruption.
What if I need community features like forums or comments?
Supabase's real-time subscriptions make it straightforward to build a live comment system or discussion forum. For something more feature-rich, integrate with Discord (gate server access based on membership tier) or embed a tool like Hyvor Talk. The point is you pick the community tool that fits, rather than being locked into whatever MemberPress's add-on ecosystem offers.
Is this approach suitable for a non-technical founder?
If you're not a developer and don't have one on your team, this probably isn't the right path. Memberful is a much better fit -- it's hosted, requires minimal setup, and integrates with most website platforms. But if you have a developer (or you're willing to hire an agency that specializes in headless builds), the custom approach will serve you far better as your membership business grows. The upfront investment pays for itself within 6-12 months for most projects we've worked on.