The good news for anyone building a SaaS on Next.js in 2026 is that the stack has finally converged. The bad news is that "converged" still means six or seven decisions where the wrong pick costs you a month of refactoring. This walkthrough is the pragmatic version: the choices we actually ship to clients, the code patterns that survive production, and the gotchas we have hit so you do not have to.
The reference architecture in this post is what we use as the default for new SaaS engagements at DesignKey Studio. It is not the only viable stack - it is the one with the best ratio of "ships fast" to "scales reasonably" for a 2026 multi-tenant B2B product.
The TL;DR
- The 2026 default stack: Next.js 16 App Router, Postgres (Supabase or Neon), Drizzle ORM, Clerk for auth, Stripe Billing for payments, Resend for transactional email, deployed on Vercel.
- Multi-tenancy via row-level security (RLS) is the right answer for almost everyone. Schema-per-tenant scales worse than people think.
- App Router server components are the productivity unlock. Direct DB queries from RSC, Server Actions for mutations, no separate API layer.
- Stripe Billing with metering became viable in 2026. The March 2026 metering release handles 80% of what teams used to build custom.
- Webhooks remain the hardest part. Not the code - the idempotency, the retry handling, and the failure modes.
- Deploy on Vercel unless you have a specific reason not to. The Edge runtime, ISR, and preview deployments are real productivity wins.
The 2026 stack at a glance
| Layer | Pick | Why |
|---|---|---|
| Framework | Next.js 16 (App Router) | Server components, streaming, built-in performance |
| Hosting | Vercel | Edge, ISR, preview deploys, zero ops |
| Database | Postgres (Supabase or Neon) | Mature, RLS support, branching (Neon) |
| ORM | Drizzle | Type-safe, lean, no codegen pain |
| Auth | Clerk | Org/team primitives built in; saves 2 months |
| Billing | Stripe Billing | The only serious answer in 2026 |
| Resend | React Email components, deliverability handled | |
| Background jobs | Inngest or Trigger.dev | Vercel-friendly, no Redis to babysit |
| Observability | Vercel Analytics + Sentry + Axiom | All Next.js-native |
There are credible alternatives at every layer. NextAuth (now Auth.js) instead of Clerk if you want to own auth. Prisma instead of Drizzle if you prefer declarative schema. Postmark instead of Resend if you need more advanced template editing. The shape of the stack matters more than the specific picks.
Project setup
npx create-next-app@latest my-saas --typescript --tailwind --app --src-dir
cd my-saas
# Core deps
npm install @clerk/nextjs drizzle-orm postgres
npm install stripe resend
npm install -D drizzle-kit @types/node
# Optional but recommended
npm install zod @hookform/resolvers react-hook-form
npm install lucide-react sonner
The --app and --src-dir flags are non-negotiable in 2026. App Router is the default and src/ keeps the project root clean.
The database schema for multi-tenancy
The single most important decision in a multi-tenant SaaS is how you isolate tenants. Three patterns:
- Schema per tenant. Each tenant gets a Postgres schema. Maximum isolation, terrible for reporting, painful migrations at scale.
- Database per tenant. Maximum isolation, terrible economics, painful for everything.
- Shared schema with
org_idcolumn on every tenant-scoped table. Best economics, best reporting, requires careful access control.
For 2026 SaaS, option 3 with Postgres row-level security is almost always right. We covered the broader pattern in Multi-Tenant SaaS Architecture Explained; this is the implementation.
The schema with Drizzle:
// src/db/schema.ts
import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core';
export const orgs = pgTable('orgs', {
id: uuid('id').primaryKey().defaultRandom(),
clerkOrgId: text('clerk_org_id').unique().notNull(),
name: text('name').notNull(),
stripeCustomerId: text('stripe_customer_id'),
plan: text('plan', { enum: ['free', 'pro', 'business'] }).default('free').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
orgId: uuid('org_id').references(() => orgs.id, { onDelete: 'cascade' }).notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const usage = pgTable('usage', {
id: uuid('id').primaryKey().defaultRandom(),
orgId: uuid('org_id').references(() => orgs.id, { onDelete: 'cascade' }).notNull(),
metric: text('metric').notNull(),
amount: integer('amount').notNull(),
recordedAt: timestamp('recorded_at').defaultNow().notNull(),
});
Every tenant-scoped table gets org_id. Cascade on delete. Indices on org_id.
RLS policy: the security backbone
Row-level security is what makes shared-schema multi-tenancy actually safe. The policy:
-- Enable RLS on every tenant-scoped table.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE usage ENABLE ROW LEVEL SECURITY;
-- Allow access only when the session's org_id matches the row's org_id.
CREATE POLICY tenant_isolation ON projects
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY tenant_isolation ON usage
USING (org_id = current_setting('app.current_org_id')::uuid);
Then in your DB client wrapper, you SET LOCAL app.current_org_id = $1 at the start of every request, scoped to that transaction. The application code can never accidentally leak across tenants because the database physically refuses to return rows from another org.
// src/db/withOrg.ts
import { sql } from 'drizzle-orm';
import { db } from './client';
export async function withOrg<T>(orgId: string, fn: () => Promise<T>): Promise<T> {
return db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL app.current_org_id = ${orgId}`);
return fn();
});
}
Every server component query, every server action mutation, every webhook handler that touches tenant data wraps in withOrg(orgId, ...). If the wrapper is missing, the query returns zero rows. The database is your last line of defense; treat it that way.
Auth and the multi-tenant route guard
Clerk is the productivity unlock for multi-tenant auth. It ships organizations as a first-class concept: users belong to orgs, orgs have roles, role-based access checks are one hook call. Building this yourself takes about two months. Clerk takes an afternoon.
The route guard in App Router:
// src/app/(app)/layout.tsx
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const { userId, orgId } = await auth();
if (!userId) redirect('/sign-in');
if (!orgId) redirect('/select-org');
return <>{children}</>;
}
The (app) route group puts every authenticated route under one layout. Marketing pages (homepage, pricing, blog) sit outside the group and stay public. The route guard runs server-side before any RSC fetches, so unauthenticated users never see a flash of protected content.
For server actions and route handlers, use the same auth() call to get orgId, then pass it to withOrg():
// src/app/(app)/projects/actions.ts
'use server';
import { auth } from '@clerk/nextjs/server';
import { withOrg } from '@/db/withOrg';
import { db } from '@/db/client';
import { projects } from '@/db/schema';
export async function createProject(name: string) {
const { userId, orgId } = await auth();
if (!userId || !orgId) throw new Error('Unauthorized');
return withOrg(orgId, async () => {
const [project] = await db.insert(projects).values({ orgId, name }).returning();
return project;
});
}
That is the complete pattern. Auth check, RLS context, query. Forty lines of boilerplate up front and every subsequent feature follows the template.
Stripe Billing: subscriptions, metering, and the webhook
Stripe Billing is the only serious payments answer for SaaS in 2026. The 2026 model has three pieces: subscription (the base plan), meters (for usage-based charges), and entitlements (what the customer can access at their plan level).
The minimal subscription create:
// src/app/api/stripe/checkout/route.ts
import { auth } from '@clerk/nextjs/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { orgId } = await auth();
if (!orgId) return new Response('Unauthorized', { status: 401 });
const { priceId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/billing/success`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/billing`,
client_reference_id: orgId,
metadata: { orgId },
});
return Response.json({ url: session.url });
}
The metadata field with orgId is critical. Every Stripe object you create from your app should carry the orgId as metadata so webhook handlers can route updates to the right tenant.
The webhook handler is where most teams cut corners and regret it:
// src/app/api/stripe/webhook/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { db } from '@/db/client';
import { orgs } from '@/db/schema';
import { eq } from 'drizzle-orm';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const signature = (await headers()).get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
// Idempotency: store event.id; ignore duplicates.
const seen = await db.query.processedWebhooks.findFirst({
where: (w, { eq }) => eq(w.id, event.id),
});
if (seen) return new Response('Already processed', { status: 200 });
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const orgId = session.metadata?.orgId;
if (!orgId) throw new Error('Missing orgId in metadata');
await db.update(orgs)
.set({ stripeCustomerId: session.customer as string, plan: 'pro' })
.where(eq(orgs.id, orgId));
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object;
await db.update(orgs)
.set({ plan: 'free' })
.where(eq(orgs.stripeCustomerId, sub.customer as string));
break;
}
// ... handle invoice.paid, invoice.payment_failed, etc.
}
await db.insert(processedWebhooks).values({ id: event.id });
return new Response('OK', { status: 200 });
} catch (err) {
console.error('Webhook handler error', err);
return new Response('Handler error', { status: 500 });
}
}
Three rules for webhooks that survive production:
- Verify the signature. Always. Without it, anyone can post fake events.
- Idempotency on
event.id. Stripe will retry. Your handler will process the same event multiple times unless you guard. - Return 200 on handled errors that should not retry. Return 5xx only when Stripe should genuinely retry. Misclassifying these is the cause of most "Stripe sent me 50 webhooks for one event" outages.
For usage-based billing, the Stripe meter API added in 2026 takes events directly:
await stripe.billing.meterEvents.create({
event_name: 'api_calls',
payload: {
stripe_customer_id: customerId,
value: '1',
},
});
Send one event per billable action. Stripe aggregates and invoices. Up to 1,000 events per second per meter, up to 100M events per month included in the standard 0.7% rate. For most SaaS that is dramatically cheaper than building it. We covered the pricing model trade-offs in SaaS Pricing Models: Tiered vs Usage vs Hybrid.
Email with Resend
Resend is the modern transactional email provider with React-component-based templates. The 2026 stack winner over SendGrid or Mailgun for new builds.
// src/lib/email.ts
import { Resend } from 'resend';
import WelcomeEmail from '@/emails/welcome';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendWelcome(to: string, firstName: string) {
return resend.emails.send({
from: 'DesignKey <hello@designkey.studio>',
to,
subject: 'Welcome to DesignKey',
react: WelcomeEmail({ firstName }),
});
}
Templates as React components means previewable in Storybook, type-safe, no separate template language. The deliverability is on par with the established players. The DX is dramatically better.
Deployment on Vercel
Push to GitHub. Connect to Vercel. Set env vars. Done. The pieces that matter:
- Preview deployments per PR make code review actually meaningful.
- ISR (Incremental Static Regeneration) for marketing pages keeps them fast and revalidates on a schedule.
- Edge runtime for auth checks and simple route handlers cuts latency.
- Environment promotion from preview to staging to production is one click.
The two gotchas:
- Function timeouts. Default 10s on Hobby, 60s on Pro, 900s on Enterprise. Webhook handlers and long-running server actions need to be aware.
- Edge runtime limitations. No Node-only APIs, no native modules. Stick to Web APIs in edge handlers.
For projects that outgrow Vercel's pricing, AWS Amplify or self-hosted Next.js on a container platform (Fly.io, Railway, Coolify) are credible alternatives. We covered the broader stack-choice math in How to Choose the Right Tech Stack.
What people get wrong
After building a dozen of these, the consistent mistakes:
- Skipping RLS because "we'll be careful in application code." You will not be careful enough. Add RLS on day one.
- Storing tenant scope in JWT claims only. JWTs can be tampered with on the client; trust the server-side
auth()call, not the token alone. - One Stripe customer per user instead of per org. Breaks the moment a user belongs to multiple orgs.
- Webhook handlers without idempotency. You will discover this when Stripe replays an old event during a network blip.
- Email from a generic domain. Deliverability tanks. Set up SPF, DKIM, and DMARC on your real domain on day one.
- No background job system. Long-running work in serverless functions hits timeouts. Inngest or Trigger.dev solve this without needing Redis.
Where to start
If you are starting fresh:
- Spin up the stack with create-next-app and add Clerk + Drizzle + Stripe. A weekend gets you signed-up users and a paywall.
- Add RLS before you write a second feature. Retrofitting is painful; designing it in is trivial.
- Wire Stripe webhooks early and test them with the Stripe CLI. Real usage will hit edge cases your tests do not.
- Set up Sentry and Axiom on day one. You cannot debug what you cannot see.
- Ship to a single beta tenant before you optimize anything. Real usage tells you which queries are slow and which UI flows are confusing. Optimization without that data is wasted effort.
If you are migrating from a single-tenant codebase, the right move is usually a parallel rebuild rather than an in-place refactor. The data model changes are foundational and the auth/billing changes are extensive. We covered the migration pattern in Idea to MVP in 30 Days and the broader build-vs-buy economics in Custom vs Off-the-Shelf: When Each Wins.
If you want a second opinion on the architecture before you commit, that is the kind of audit we run as part of our SaaS Development service and Backend and Cloud service. The first conversation is free, and we will tell you straight when an off-the-shelf SaaS framework would beat building from scratch.
Want a second opinion on your Next.js SaaS architecture? Contact us for a free 30-minute consultation.