In 2026, you can build a production-ready B2B SaaS in 21 days with the right architecture. Not a prototype. Not a demo. A real product with authentication, billing, analytics, error tracking, and deployment that scales to thousands of users.
I've shipped 7 products using this exact process. Every decision here is battle-tested in production. This is the guide I wish I had when I started.
The Architecture Overview
The stack: Next.js 16 (App Router + React Server Components), TypeScript (strict mode), Supabase (PostgreSQL + Auth + Storage), Stripe (payments + webhooks), Vercel (deployment), PostHog (analytics), and Sentry (error tracking).
This isn't just a list of tools. It's a complete system where each piece handles exactly one concern exceptionally well.
Day 1-2: Foundation & Database Design
Database Schema First
Start with the database. Not the UI. Not the API. The schema defines your product's capabilities and constraints. Get this right and everything else follows naturally.
-- Core tables for B2B SaaS
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
owner_id UUID REFERENCES users(id),
stripe_customer_id TEXT UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE memberships (
user_id UUID REFERENCES users(id),
org_id UUID REFERENCES organizations(id),
role TEXT CHECK (role IN ('owner', 'admin', 'member')),
PRIMARY KEY (user_id, org_id)
);
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
org_id UUID REFERENCES organizations(id),
stripe_subscription_id TEXT UNIQUE,
status TEXT NOT NULL,
plan TEXT NOT NULL,
current_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);Row Level Security Policies
-- Users can only read their own data
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_own_data" ON users
FOR ALL USING (auth.uid() = id);
-- Members can access organization data
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "members_read_orgs" ON organizations
FOR SELECT USING (
id IN (
SELECT org_id FROM memberships
WHERE user_id = auth.uid()
)
);
-- Only owners can update organizations
CREATE POLICY "owners_update_orgs" ON organizations
FOR UPDATE USING (
owner_id = auth.uid()
);Day 3-5: Authentication & User Management
Supabase Auth Setup
Don't build auth from scratch. Supabase Auth handles email/password, OAuth (Google, GitHub), magic links, session management, and password reset flows out of the box.
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createServerClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
},
}
);
}Protected Route Middleware
// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const supabase = createServerClient(/* ... */);
const { data: { session } } = await supabase.auth.getSession();
// Redirect to login if not authenticated
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};Day 6-10: Stripe Integration & Billing
Subscription Setup
Stripe is non-negotiable for B2B SaaS. The investment in learning Stripe properly pays off immediately | their customer portal alone saves weeks of development time.
// app/api/checkout/route.ts
import Stripe from 'stripe';
import { createServerClient } from '@/lib/supabase/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const supabase = await createServerClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const { priceId, orgId } = await req.json();
// Get or create Stripe customer
const { data: org } = await supabase
.from('organizations')
.select('stripe_customer_id')
.eq('id', orgId)
.single();
let customerId = org?.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email,
metadata: { org_id: orgId },
});
customerId = customer.id;
await supabase
.from('organizations')
.update({ stripe_customer_id: customerId })
.eq('id', orgId);
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
metadata: { org_id: orgId },
});
return Response.json({ url: checkoutSession.url });
}Webhook Handler with Idempotency
Webhooks are where most developers get burned. Stripe can send the same event multiple times. Your handler must be idempotent | processing the same event twice should produce the same result.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // Use service role for webhooks
);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
// Check if we've already processed this event (idempotency)
const { data: existing } = await supabase
.from('webhook_events')
.select('id')
.eq('stripe_event_id', event.id)
.single();
if (existing) {
return Response.json({ received: true });
}
// Record the event
await supabase
.from('webhook_events')
.insert({ stripe_event_id: event.id, type: event.type });
// Handle the event
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
);
await supabase.from('subscriptions').upsert({
org_id: session.metadata?.org_id,
stripe_subscription_id: subscription.id,
status: subscription.status,
plan: subscription.items.data[0].price.id,
current_period_end: new Date(subscription.current_period_end * 1000),
});
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await supabase
.from('subscriptions')
.update({
status: subscription.status,
current_period_end: new Date(subscription.current_period_end * 1000),
})
.eq('stripe_subscription_id', subscription.id);
break;
}
}
return Response.json({ received: true });
}Day 11-14: Core Feature Development
Server Components & Server Actions
React Server Components are production-grade in 2026. They enable direct database queries from components and eliminate the need for most API routes.
// app/dashboard/page.tsx (Server Component)
import { createServerClient } from '@/lib/supabase/server';
import { DashboardClient } from './DashboardClient';
export default async function DashboardPage() {
const supabase = await createServerClient();
const { data: { session } } = await supabase.auth.getSession();
// Direct database query - no API route needed
const { data: projects } = await supabase
.from('projects')
.select('*')
.eq('user_id', session!.user.id)
.order('created_at', { ascending: false });
return <DashboardClient initialProjects={projects || []} />;
}
// Server Action for mutations
'use server';
import { revalidatePath } from 'next/cache';
export async function createProject(formData: FormData) {
const supabase = await createServerClient();
const { data: { session } } = await supabase.auth.getSession();
const title = formData.get('title') as string;
await supabase.from('projects').insert({
title,
user_id: session!.user.id,
});
revalidatePath('/dashboard');
}Day 15-17: Monitoring & Error Tracking
Sentry for Error Tracking
// instrumentation.ts
import * as Sentry from '@sentry/nextjs';
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.1,
environment: process.env.NODE_ENV,
});
}PostHog for Analytics
// lib/posthog/provider.tsx
'use client';
import posthog from 'posthog-js';
import { PostHogProvider } from 'posthog-js/react';
import { useEffect } from 'react';
export function PHProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: '/ingest',
capture_pageview: false,
});
}
}, []);
return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}Day 18-19: SEO & Performance
Metadata & OpenGraph
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Your SaaS - tagline',
description: 'Description for SEO',
openGraph: {
title: 'Your SaaS',
description: 'Description for social sharing',
url: 'https://yoursaas.com',
type: 'website',
images: [{
url: 'https://yoursaas.com/og-image.png',
width: 1200,
height: 630,
}],
},
twitter: {
card: 'summary_large_image',
title: 'Your SaaS',
description: 'Description for Twitter',
images: ['https://yoursaas.com/og-image.png'],
},
};Sitemap Generation
// app/sitemap.ts
import { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://yoursaas.com',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: 'https://yoursaas.com/pricing',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
];
}Day 20-21: Deployment & Launch
Vercel Deployment
Vercel deployment is one command: vercel --prod. But there's a pre-flight checklist you must complete first.
Environment Variables
# Production .env (set in Vercel dashboard)
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_key
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_SENTRY_DSN=https://...Production Cost Breakdown
Here's what running this stack actually costs at different scales:
- 0-1,000 users: ~$45/month (Supabase Pro $25 + Vercel Pro $20)
- 1,000-10,000 users: ~$95/month (add Vercel usage $50)
- 10,000-50,000 users: ~$245/month (Supabase Team $100 + Vercel $145)
Security Checklist
Before you launch, verify every item on this security checklist:
- RLS enabled on all Supabase tables
- All API routes check authentication
- Stripe webhook signatures verified
- Service role key never exposed client-side
- CSRF protection on all forms
- Rate limiting on auth endpoints
- SQL injection prevention (use parameterized queries)
- XSS prevention (Next.js handles this by default)
- HTTPS enforced (Vercel handles this)
- Security headers configured
Post-Launch: What to Monitor
In the first 30 days after launch, monitor these metrics obsessively:
- Error rate (Sentry) | should be under 0.1%
- API response times (Vercel analytics) | p95 under 1s
- Conversion funnel (PostHog) | where users drop off
- Failed payments (Stripe dashboard) | investigate every one
- User feedback | qualitative data beats metrics
Common Pitfalls & How to Avoid Them
1. The N+1 Query Problem
// BAD: N+1 queries
const projects = await supabase.from('projects').select('*');
for (const project of projects.data) {
const tasks = await supabase.from('tasks').select('*').eq('project_id', project.id);
project.tasks = tasks.data;
}
// GOOD: Single query with join
const projects = await supabase
.from('projects')
.select('*, tasks(*)')
.limit(100);2. Missing Database Indexes
-- Always index foreign keys and frequently queried columns
CREATE INDEX idx_projects_user_id ON projects(user_id);
CREATE INDEX idx_tasks_project_id ON tasks(project_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_subscriptions_org_id ON subscriptions(org_id);3. Not Handling Stripe Edge Cases
Payments fail. Customers dispute charges. Subscriptions get paused, canceled, and reactivated. Your webhook handler must handle all of these states gracefully.
The Real Timeline
21 days is realistic for an experienced developer with this exact stack. If you're learning as you build, expect 40-50 days. That's still faster than most agencies promise.
What This Guide Doesn't Cover
This is a complete technical foundation, but building a successful SaaS requires more: customer research before you write code, marketing strategy from day one, pricing experimentation based on value delivered, customer support infrastructure, and continuous iteration based on user feedback.
The technical stack gets you to launch. What happens next depends on how well you understand your users.
Ready to Build?
This is the exact architecture I use to ship production SaaS in 14-21 days. Every pattern here is from real products serving real users. No theory. No toy examples. Just production-tested code.
If you want this built for you | with all the patterns, security, monitoring, and deployment handled correctly from day one , book a call. I ship production-ready SaaS in 14-21 days. You own all the code. No vendor lock-in.
Or if you're building it yourself, bookmark this guide and use it as your checklist. Every technical decision here is something I've tested in production across 7 different products.
Questions about any part of the stack? Find me on Twitter @m_tanveerabbas. I respond to every DM.
