Building a Production-Ready SaaS Starter Kit from Scratch: Architecture & Implementation
Every SaaS journey begins with the "blank canvas" problem. You have a brilliant product idea, but before you can write a single line of business logic, you need to wire up authentication, set up a database, integrate Stripe, configure transactional emails, and establish a CI/CD pipeline. By the time you reach your first paying customer, you’ve spent three weeks just building the scaffolding.
As mid-to-senior developers, we know that a SaaS starter kit isn't just about saving time; it's about establishing a robust architectural foundation. A poorly designed starter kit will haunt you with scaling issues, security vulnerabilities, and developer friction down the line.
In this deep dive, we will architect a production-ready SaaS starter from scratch. We’ll bypass the superficial tutorials and focus on the critical architecture decisions that separate hobby projects from scalable, enterprise-grade SaaS products.
High-Level Architecture
Before writing code, we must define our boundaries. A modern SaaS architecture should leverage the edge for performance and keep the database strictly isolated.
[Client] --> [Edge Network / CDN] --> [Next.js Edge Middleware (Auth/Routing)]
|
v
[Next.js API Routes / Server Actions]
|
+----------------------+----------------------+
| | |
[PostgreSQL DB] [Stripe Webhooks] [Resend/Email API]
This architecture relies on Next.js App Router, utilizing Edge Middleware for zero-latency route protection, Server Actions for mutations, and a strict multi-tenant database design.
1. Authentication & Edge Middleware
The most common pitfall in SaaS authentication is checking user permissions inside individual API routes or components. This leads to duplicated logic and security holes. The solution is to centralize auth at the edge.
We use Auth.js (formerly NextAuth) with a JWT strategy. Why JWT? Because it allows the Edge Middleware to verify sessions without hitting the database on every request, keeping latency under 10ms.
The Pitfall: Over-fetching in Middleware
A common mistake is trying to fetch the full user object in the middleware. Middleware runs on the Edge (V8 isolates), which has strict limitations on Node.js APIs and database connections.
The Solution: Lightweight JWT Payloads
Keep the JWT payload minimal. Only include the userId, email, and role. Fetch the full user profile in Server Components or Server Actions where you have full database access.
Here is a production-grade Edge Middleware that handles route protection, role-based access, and tenant routing:
// src/middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
const PUBLIC_PATHS = ['/', '/login', '/register', '/api/webhooks', '/api/auth'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Allow public paths and static assets
if (PUBLIC_PATHS.some(path => pathname.startsWith(path)) || pathname.includes('.')) {
return NextResponse.next();
}
// 2. Verify JWT at the edge (No DB hit)
const token = await getToken({ req: request, secret: process.env.AUTH_SECRET });
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// 3. Role-based route protection
if (pathname.startsWith('/admin') && token.role !== 'ADMIN') {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// 4. Inject user context into headers for downstream Server Actions/RSC
const response = NextResponse.next();
response.headers.set('x-user-id', token.sub!);
response.headers.set('x-user-role', token.role || 'USER');
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Performance Consideration: By handling auth at the edge, we prevent unauthorized requests from ever reaching your origin servers, saving compute costs and improving security.
2. Multi-Tenant Database & Data Isolation
Multi-tenancy is where many SaaS products fail. Whether you choose a shared database with row-level security (RLS) or a database-per-tenant, the biggest risk is data leakage.
If you are using Prisma, relying on developers to manually add where: { tenantId: ctx.tenantId } to every single query is a recipe for disaster. One forgotten clause, and Tenant A sees Tenant B's data.
The Solution: Prisma Client Extensions
Instead of manual scoping, we can use Prisma Client Extensions to automatically inject the tenantId into all read and write operations. This creates a bulletproof abstraction layer.
// src/lib/db/tenant-client.ts
import { PrismaClient } from '@prisma/client';
// We extend the base client to enforce tenant isolation
export function createTenantClient(tenantId: string) {
const prisma = new PrismaClient().$extends({
query: {
$allModels: {
async findMany({ model, args, query }) {
// Automatically inject tenantId into all findMany queries
args.where = { ...args.where, tenantId };
return query({ model, args });
},
async create({ model, args, query }) {
// Automatically inject tenantId into all create mutations
args.data = { ...args.data, tenantId };
return query({ model, args });
},
async update({ model, args, query }) {
args.where = { ...args.where, tenantId };
return query({ model, args });
},
async delete({ model, args, query }) {
args.where = { ...args.where, tenantId };
return query({ model, args });
},
// Note: In a real app, you'd also cover findFirst, findUnique, updateMany, deleteMany, etc.
},
},
});
return prisma;
}
In your Server Actions or API routes, you simply initialize this client:
const db = createTenantClient(session.user.tenantId);
const projects = await db.project.findMany(); // tenantId is automatically applied!
Pitfall Avoided: This completely eliminates the risk of accidental cross-tenant data leakage due to developer error. The database layer itself enforces the business logic.
3. Billing, Webhooks, and Idempotency
Integrating Stripe is easy; handling Stripe webhooks reliably is hard. The most common failure mode is processing the same webhook twice (e.g., due to Stripe retrying after a timeout), leading to duplicated subscriptions or double-charged credits.
The Solution: Idempotency Keys and Signature Verification
Every webhook event from Stripe has a unique ID. We must store this ID and check it before processing the event. Furthermore, we must verify the webhook signature to ensure the payload wasn't tampered with.
Here is a robust Next.js Route Handler for Stripe webhooks:
// src/app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { createAdminClient } from '@/lib/db/admin-client';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const db = createAdminClient(); // Admin client bypasses tenant scoping for webhooks
export async function POST(req: Request) {
const body = await req.text();
const headersList = headers();
const signature = headersList.get('stripe-signature');
let event: Stripe.Event;
try {
// 1. Verify signature to prevent spoofing
event = stripe.webhooks.constructEvent(body, signature!, webhookSecret);
} catch (err: any) {
console.error(`Webhook signature verification failed: ${err.message}`);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// 2. Idempotency check: Have we already processed this exact event?
const existingEvent = await db.processedWebhook.findUnique({
where: { stripeEventId: event.id },
});
if (existingEvent) {
// Already processed, return 200 to tell Stripe to stop retrying
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
// 3. Process the event based on type
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
// Fulfill the order, create subscription, etc.
await fulfillCheckout(session);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await updateSubscriptionStatus(subscription);
break;
}
// Handle other events...
}
// 4. Record the event as processed to ensure idempotency
await db.processedWebhook.create({
data: { stripeEventId: event.id, type: event.type },
});
return new Response(JSON.stringify({ received: true }), { status: 200 });
} catch (error) {
console.error(`Webhook handler failed for event ${event.id}:`, error);
// Return 500 so Stripe will retry the webhook
return new Response(`Webhook handler failed`, { status: 500 });
}
}
Performance Consideration: Webhook handlers should be fast. If your fulfillment logic takes too long (e.g., sending emails, generating reports), offload it to a background job queue (like Inngest, Trigger.dev, or BullMQ) immediately after recording the webhook ID.
4. Email, Deployment, and Developer Experience
Transactional Email
For emails, we use React Email combined with Resend. The key architectural decision here is never sending emails synchronously in the request lifecycle. Always queue them. Using a tool like Trigger.dev or a simple database-backed queue ensures that a slow email API doesn't block your user's UI.
Deployment Strategy
Deploy to Vercel or Cloudflare Pages. Utilize the Edge Runtime for API routes where possible to take advantage of global distribution. However, keep database-heavy routes in the standard Node.js runtime to avoid cold starts and connection pool exhaustion.
Developer Experience (DX)
A starter kit is only as good as its DX.
- Monorepo: Use
pnpmworkspaces and Turborepo. Keep your web app, shared UI components, and database schema in one repository. - Strict Typing: Enable
strict: trueintsconfig.json. Use Zod for runtime validation of API inputs. - Linting & Formatting: Biome or ESLint + Prettier configured to run on pre-commit via Husky.
Key Takeaways
- Shift Security Left: Handle auth and tenant isolation at the lowest possible layer (Edge Middleware and DB extensions) rather than in UI components.
- Embrace Idempotency: Assume webhooks will be delivered multiple times. Design your billing and state-mutation logic to be safely re-runnable.
- Optimize for the Edge: Use JWTs for edge auth, keep payloads small, and separate your edge-compatible routes from your heavy database routes.
- Abstract the Boring Stuff: A good starter kit removes the cognitive load of boilerplate so you can focus on your unique value proposition.
Building a SaaS is a marathon, not a sprint. The architecture you choose on day one will dictate your velocity on day one hundred. When I was architecting PubliFlow (publiflow.vip), a Next.js 15 SaaS starter kit I recently released, I applied this exact tenant-scoped database pattern and edge middleware setup. Finding the right balance between edge performance and database consistency was the biggest hurdle, but abstracting it into a reusable starter made all the difference.
Take these patterns, adapt them to your stack, and stop reinventing the wheel. Your future self, staring at a scaling production app, will thank you.
Top comments (0)