DEV Community

PubliFlow
PubliFlow

Posted on

Building a Production-Ready SaaS Starter Kit from Scratch: Architecture & Implementation

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]
Enter fullscreen mode Exit fullscreen mode

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).*)'],
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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 pnpm workspaces and Turborepo. Keep your web app, shared UI components, and database schema in one repository.
  • Strict Typing: Enable strict: true in tsconfig.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

  1. Shift Security Left: Handle auth and tenant isolation at the lowest possible layer (Edge Middleware and DB extensions) rather than in UI components.
  2. Embrace Idempotency: Assume webhooks will be delivered multiple times. Design your billing and state-mutation logic to be safely re-runnable.
  3. Optimize for the Edge: Use JWTs for edge auth, keep payloads small, and separate your edge-compatible routes from your heavy database routes.
  4. 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)