DEV Community

Henrique Rabelo
Henrique Rabelo

Posted on

I built a production-ready SaaS boilerplate - here's what I learned

Every SaaS boilerplate I tried had the same problem: auth worked, but multi-tenancy was fake.

You know what I mean. The "multi-tenant" feature was just a userId filter in every query. No data isolation. No Row-Level Security. Just hope that no one forgets a WHERE clause.

After building my third SaaS from scratch and copy-pasting the same auth, team management, and permissions code again, I decided to extract it properly. Not another todo app with Stripe - but the actual foundation I use in production.

Today I'm open-sourcing it: saas-root.

The Problem with Existing Boilerplates

I've tried at least 10 SaaS boilerplates. Most fall into two categories:

Category 1: Too Simple

  • Basic auth with Supabase/NextAuth
  • Single users table
  • "Add your own multi-tenancy" (thanks, that's the hard part)

Category 2: Too Complex

  • 50+ tables on day one
  • Kubernetes deployment config
  • Enterprise features you'll never use
  • $299 price tag

What I actually needed was something in between: production-ready foundations without the bloat.

What "Production-Ready" Actually Means

After shipping real products, I learned that a production-ready foundation needs:

1. True Data Isolation (RLS)

Not filters in your application code. Database-level security.

Here's how I set up Row-Level Security for organizations:

-- Enable RLS
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;

-- Users can only see organizations they belong to
CREATE POLICY "Users can view their organizations"
  ON organizations FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM organization_memberships
      WHERE organization_memberships.organization_id = organizations.id
      AND organization_memberships.user_id = auth.uid()
    )
  );

-- Only owners can update organization settings
CREATE POLICY "Owners can update organization"
  ON organizations FOR UPDATE
  USING (
    EXISTS (
      SELECT 1 FROM organization_memberships
      WHERE organization_memberships.organization_id = organizations.id
      AND organization_memberships.user_id = auth.uid()
      AND organization_memberships.role = 'owner'
    )
  );
Enter fullscreen mode Exit fullscreen mode

This runs at the database level. Even if your application code has a bug, the wrong user cannot see another organization's data. Period.

2. Authorization Beyond RBAC

Simple role-based access (owner/admin/member) works for 80% of cases. But what about:

  • "Members can only edit their own tasks"
  • "Admins can invite, but not delete members"
  • "Only the creator can archive a project"

I use CASL for this. It lets you define granular permissions:

// Define what each role can do
const ownerAbilities = [
  { action: 'manage', subject: 'Organization' },
  { action: 'manage', subject: 'Member' },
  { action: 'manage', subject: 'Project' },
];

const memberAbilities = [
  { action: 'read', subject: 'Organization' },
  { action: 'read', subject: 'Member' },
  { action: 'read', subject: 'Project' },
  { action: ['create', 'update', 'delete'], subject: 'Task', conditions: { createdBy: '${user.id}' } },
];
Enter fullscreen mode Exit fullscreen mode

The conditions field is powerful - members can only modify tasks they created. This is evaluated at runtime and works with your existing queries.

3. Provider Abstraction

Hardcoding Resend into your email service? What happens when you need to switch to SendGrid or AWS SES?

I learned this the hard way. Now I use interfaces:

export abstract class EmailProvider {
  abstract readonly name: string;

  abstract send(params: SendEmailParams): Promise<SendEmailResult>;

  abstract sendTemplate<T>(params: SendTemplateParams<T>): Promise<SendEmailResult>;
}
Enter fullscreen mode Exit fullscreen mode

Switching providers means implementing this interface - not rewriting your entire email flow.

Currently saas-root ships with:

  • Resend - My default for simplicity
  • SMTP - For self-hosted setups
  • Console - For development (logs emails to terminal)

Same pattern works for payments, file storage, push notifications.

Technical Decisions

Why NestJS + Next.js?

Backend (NestJS):

  • Structured architecture that scales
  • Dependency injection out of the box
  • Easy to test
  • TypeScript native

Frontend (Next.js 16):

  • App Router with Server Components
  • The React framework everyone knows
  • Great deployment story (Vercel, self-hosted)

Could you use Express? Sure. But you'll rebuild NestJS patterns anyway.

Why Drizzle over Prisma?

I used Prisma for years. Drizzle wins for me because:

  1. SQL-like syntax - If you know SQL, you know Drizzle
  2. No runtime engine - Just generates SQL
  3. Better type inference - Less as casting
  4. Faster migrations - drizzle-kit push is instant
// Drizzle: Feels like writing SQL
const members = await db
  .select()
  .from(memberships)
  .where(eq(memberships.organizationId, orgId))
  .leftJoin(profiles, eq(memberships.userId, profiles.id));
Enter fullscreen mode Exit fullscreen mode

Why Supabase?

Supabase gives you:

  • PostgreSQL with RLS built-in
  • Auth with OAuth providers
  • Realtime subscriptions
  • Storage

All with a generous free tier. For indie hackers building MVPs, it's the obvious choice.

What's Included

Feature Description
Authentication Supabase Auth (email, Google, GitHub)
Multi-tenancy Organizations with RLS
Authorization CASL with roles + custom permissions
Team Management Invitations, role changes, member list
Email System Provider abstraction + templates
Feature Flags Plan-based gating
Projects & Tasks Example CRUD with soft delete
UI Components 30+ shadcn/ui components

Quick Start

# Clone
git clone https://github.com/ohenriquesilvar/saas-root
cd saas-root

# Install
cd backend && npm install
cd ../frontend && npm install

# Start Supabase (local)
supabase start

# Configure env
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env.local

# Run migrations & start
cd backend && npm run db:push && npm run start:dev
cd ../frontend && npm run dev
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:3000. Register, create an organization, invite team members.

What's NOT Included (By Design)

  • Billing/Stripe - Different for every business model
  • Background Jobs - Use BullMQ, Trigger.dev, or whatever fits
  • Docker/K8s - Start simple, add when needed
  • CI/CD - Every team has preferences

These are features you add when you need them. The boilerplate gives you the foundation.

Why Open Source?

I've been building in public for a while. The indie hacker community has taught me a lot. This is my way of giving back.

MIT license. No catch. Use it for your startup, client projects, whatever.

If it saves you a few weeks, maybe drop a star on GitHub. That's all I ask.


Links:

What features would you add? Let me know in the comments.

Top comments (0)