IAMUVIN

Startup & Product Building

Building a SaaS Product with Next.js and Supabase: The Complete Stack

Uvin Vindula·July 7, 2025·13 min read
Share

TL;DR

I have built SaaS products and client dashboards using the same stack for two years now: Next.js 16 + Supabase + Stripe + Tailwind CSS v4 + Vercel. This is the complete technical walkthrough — from designing a multi-tenant database schema in PostgreSQL to wiring up Stripe subscriptions, building role-based dashboards, sending transactional emails with Resend, and deploying to Vercel with zero downtime. I am sharing the exact architecture decisions, code patterns, and cost breakdown so you can either build it yourself or understand what you are paying for when you hire someone like me. A production SaaS on this stack costs between $8,000 and $35,000 to build depending on scope, and the infrastructure runs for under $50 per month until you hit serious scale.


The SaaS Stack

Every SaaS I build starts with the same core decision: what is the stack that lets me ship fast without creating technical debt I will regret in six months? After trying Rails, Laravel, Django, and various Node.js combinations, I settled on a stack I have not changed in two years.

The stack:

LayerTechnologyWhy
FrameworkNext.js 16 (App Router)Server Components, Server Actions, streaming, ISR — the most complete React framework
DatabaseSupabase (PostgreSQL)Managed Postgres with auth, realtime, storage, and edge functions built in
AuthSupabase AuthEmail, magic link, OAuth providers — no separate auth service needed
PaymentsStripeSubscriptions, invoicing, payment links, customer portal — the industry standard
StylingTailwind CSS v4CSS-first config, design tokens as CSS variables, fast iteration
EmailResendReact-based email templates, reliable delivery, simple API
DeploymentVercelZero-config Next.js deployment, preview environments, edge network
ValidationZodRuntime type checking for API inputs, form data, and webhook payloads
ORMPrisma (optional)Type-safe database queries when you outgrow the Supabase client

This stack has three properties I care about. First, it is vertically integrated — Supabase handles auth, database, storage, and realtime in one service, which means fewer moving parts and fewer bills. Second, it scales down — you can run a production SaaS on the free tier of every service until you have paying customers. Third, it scales up — PostgreSQL is PostgreSQL, Vercel's edge network is fast everywhere, and Stripe handles billions in volume.

The total infrastructure cost for a SaaS with under 1,000 users: roughly $25 to $45 per month. That is Supabase Pro ($25), Vercel Pro ($20), Stripe (2.9% + 30 cents per transaction), and Resend free tier. Compare that to running your own servers.

next.js 16 ─── app router ─── server components
     │                              │
     ├── server actions ────── zod validation
     │                              │
     ├── supabase client ───── postgresql
     │       │                      │
     │       ├── auth ──────── rls policies
     │       ├── storage ───── file uploads
     │       └── realtime ──── live updates
     │
     ├── stripe ────────────── subscriptions
     │       └── webhooks ──── billing events
     │
     ├── resend ────────────── transactional email
     │
     └── vercel ────────────── deployment + cdn

Database Schema for Multi-Tenancy

The first architectural decision in any SaaS is the tenancy model. There are three options: database-per-tenant, schema-per-tenant, and shared database with tenant IDs. I use the third option for every project under $100K ARR.

Shared database with tenant isolation through Row Level Security (RLS) gives you simplicity without sacrificing data isolation. Every table gets an organization_id column, and Supabase RLS policies ensure that users can only see data belonging to their organization. No application-level filtering required — the database enforces it.

Here is the core schema I start every SaaS project with:

sql
-- Organizations (tenants)
create table organizations (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  slug text unique not null,
  stripe_customer_id text unique,
  stripe_subscription_id text,
  subscription_status text default 'trialing',
  subscription_tier text default 'free',
  trial_ends_at timestamptz default now() + interval '14 days',
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- User profiles (extends Supabase auth.users)
create table profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  full_name text,
  avatar_url text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Organization memberships (many-to-many)
create table memberships (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references profiles(id) on delete cascade,
  organization_id uuid not null references organizations(id) on delete cascade,
  role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
  created_at timestamptz default now(),
  unique(user_id, organization_id)
);

-- Example domain table with tenant isolation
create table projects (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid not null references organizations(id) on delete cascade,
  name text not null,
  description text,
  status text default 'active' check (status in ('active', 'archived', 'deleted')),
  created_by uuid references profiles(id),
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Indexes for performance
create index idx_memberships_user on memberships(user_id);
create index idx_memberships_org on memberships(organization_id);
create index idx_projects_org on projects(organization_id);
create index idx_organizations_stripe on organizations(stripe_customer_id);

The three-table foundation — organizations, profiles, memberships — handles every multi-tenant SaaS pattern. A user can belong to multiple organizations. Each organization has its own subscription. Domain tables like projects reference the organization, and RLS policies handle the rest.

The RLS policy for the projects table:

sql
alter table projects enable row level security;

create policy "Users can view projects in their organizations"
  on projects for select
  using (
    organization_id in (
      select organization_id from memberships
      where user_id = auth.uid()
    )
  );

create policy "Admins and owners can insert projects"
  on projects for insert
  with check (
    organization_id in (
      select organization_id from memberships
      where user_id = auth.uid()
      and role in ('owner', 'admin')
    )
  );

This pattern means I never write WHERE organization_id = ? in my application code. The database handles it. Every query automatically returns only data the current user is authorized to see. It is the single most important security pattern in multi-tenant SaaS.


Authentication and User Management

Supabase Auth handles the heavy lifting. I configure it with email/password and Google OAuth for most SaaS products — those two cover 95% of users. The implementation in Next.js 16 uses server-side auth with the @supabase/ssr package.

typescript
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          );
        },
      },
    }
  );
}

The auth flow I use for every SaaS:

  1. User signs up with email or Google OAuth
  2. A database trigger creates their profile row automatically
  3. User creates or joins an organization
  4. A membership row is created linking user to organization
  5. The active organization is stored in a cookie for fast access

The signup trigger:

sql
create or replace function handle_new_user()
returns trigger as $$
begin
  insert into profiles (id, full_name, avatar_url)
  values (
    new.id,
    coalesce(new.raw_user_meta_data->>'full_name', ''),
    coalesce(new.raw_user_meta_data->>'avatar_url', '')
  );
  return new;
end;
$$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function handle_new_user();

For the organization creation flow, I use a Server Action that creates the org and the owner membership in a single transaction:

typescript
// app/actions/organization.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { z } from "zod";

const CreateOrgSchema = z.object({
  name: z.string().min(2).max(100),
  slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/),
});

export async function createOrganization(formData: FormData) {
  const supabase = await createClient();

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const parsed = CreateOrgSchema.safeParse({
    name: formData.get("name"),
    slug: formData.get("slug"),
  });

  if (!parsed.success) {
    return { error: "Invalid organization details" };
  }

  const { data: org, error: orgError } = await supabase
    .from("organizations")
    .insert({ name: parsed.data.name, slug: parsed.data.slug })
    .select()
    .single();

  if (orgError) {
    return { error: "Could not create organization" };
  }

  await supabase.from("memberships").insert({
    user_id: user.id,
    organization_id: org.id,
    role: "owner",
  });

  redirect(`/dashboard/${org.slug}`);
}

Every form input goes through Zod validation. Every database operation checks for the authenticated user first. Every error returns a structured response. No exceptions.


Subscription Billing with Stripe

Stripe integration is where most SaaS tutorials fall apart — they show you how to create a checkout session but skip the webhook handling that actually matters. Webhooks are the source of truth for subscription state, not the checkout success page.

Here is how I wire up Stripe in every SaaS project.

Step 1: Create products and prices in Stripe. I define three tiers — Free, Pro, and Team — with monthly and annual pricing. This happens in the Stripe dashboard or via the API during setup.

Step 2: Create a checkout session from a Server Action.

typescript
// app/actions/billing.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import Stripe from "stripe";
import { redirect } from "next/navigation";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createCheckoutSession(priceId: string) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const { data: org } = await supabase
    .from("memberships")
    .select("organizations(*)")
    .eq("user_id", user.id)
    .eq("role", "owner")
    .single();

  if (!org?.organizations) {
    return { error: "Organization not found" };
  }

  const organization = org.organizations;
  let customerId = organization.stripe_customer_id;

  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      metadata: { organization_id: organization.id },
    });
    customerId = customer.id;

    await supabase
      .from("organizations")
      .update({ stripe_customer_id: customerId })
      .eq("id", organization.id);
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: "subscription",
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing?canceled=true`,
    subscription_data: {
      metadata: { organization_id: organization.id },
    },
  });

  redirect(session.url!);
}

Step 3: Handle webhooks. This is the critical part. The webhook handler listens for subscription events and updates the organization's subscription state in the database. Every billing state change flows through here.

typescript
// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";
import { createServiceClient } from "@/lib/supabase/admin";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const body = await request.text();
  const headersList = await headers();
  const signature = headersList.get("stripe-signature")!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  const supabase = createServiceClient();

  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      const orgId = subscription.metadata.organization_id;

      await supabase
        .from("organizations")
        .update({
          stripe_subscription_id: subscription.id,
          subscription_status: subscription.status,
          subscription_tier: determineTier(subscription),
          updated_at: new Date().toISOString(),
        })
        .eq("id", orgId);
      break;
    }

    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      const orgId = subscription.metadata.organization_id;

      await supabase
        .from("organizations")
        .update({
          subscription_status: "canceled",
          subscription_tier: "free",
          updated_at: new Date().toISOString(),
        })
        .eq("id", orgId);
      break;
    }
  }

  return new Response("OK", { status: 200 });
}

function determineTier(subscription: Stripe.Subscription): string {
  const priceId = subscription.items.data[0]?.price.id;
  const tierMap: Record<string, string> = {
    [process.env.STRIPE_PRO_PRICE_ID!]: "pro",
    [process.env.STRIPE_TEAM_PRICE_ID!]: "team",
  };
  return tierMap[priceId] ?? "free";
}

I use a service client (with the service role key) for webhook handlers because webhooks do not carry a user session. The service client bypasses RLS, which is necessary for admin-level database updates. This client is never exposed to the browser.

Two rules I follow without exception: always verify the webhook signature, and always use the webhook event as the source of truth for subscription state — never trust the client-side redirect.


Dashboard Architecture

The dashboard is where your SaaS lives. I structure every dashboard the same way because consistency reduces bugs and speeds up development.

app/
  dashboard/
    layout.tsx          ← Sidebar + auth guard
    page.tsx            ← Overview / home
    billing/
      page.tsx          ← Subscription management
    settings/
      page.tsx          ← Organization settings
    members/
      page.tsx          ← Team management
    [domain]/           ← Your SaaS-specific pages
      page.tsx
      [id]/
        page.tsx

The dashboard layout handles three things: authentication, organization context, and navigation.

typescript
// app/dashboard/layout.tsx
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/dashboard/sidebar";
import { OrgProvider } from "@/components/providers/org-provider";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) redirect("/login");

  const { data: membership } = await supabase
    .from("memberships")
    .select("role, organizations(*)")
    .eq("user_id", user.id)
    .limit(1)
    .single();

  if (!membership?.organizations) redirect("/onboarding");

  return (
    <OrgProvider
      organization={membership.organizations}
      role={membership.role}
    >
      <div className="flex h-screen">
        <Sidebar />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </OrgProvider>
  );
}

The layout is a Server Component. It fetches the user and organization on the server, passes them down through a context provider, and renders the sidebar. No loading spinners. No client-side auth checks. The page loads with data already present.

For dashboard pages, I use a pattern I call "server fetch, client interact." The page component is a Server Component that fetches data. Interactive elements — tables with sorting, forms, modals — are Client Components that receive data as props.

typescript
// app/dashboard/projects/page.tsx
import { createClient } from "@/lib/supabase/server";
import { ProjectsTable } from "@/components/dashboard/projects-table";
import { CreateProjectButton } from "@/components/dashboard/create-project-button";

export default async function ProjectsPage() {
  const supabase = await createClient();

  const { data: projects } = await supabase
    .from("projects")
    .select("*")
    .order("created_at", { ascending: false });

  return (
    <div>
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-semibold">Projects</h1>
        <CreateProjectButton />
      </div>
      <ProjectsTable projects={projects ?? []} />
    </div>
  );
}

No fetch waterfalls. No useEffect data loading. No loading states on initial render. The data is there when the page arrives. This is the performance advantage of Server Components in a SaaS dashboard — every page loads with data already rendered on the server.


Role-Based Access Control

RBAC in a SaaS is not optional — it is the difference between a toy and a product. I implement four roles: owner, admin, member, and viewer. The permissions matrix is simple and enforced at three levels.

typescript
// lib/permissions.ts
type Role = "owner" | "admin" | "member" | "viewer";

type Permission =
  | "org:manage"
  | "org:billing"
  | "org:delete"
  | "members:invite"
  | "members:remove"
  | "members:change_role"
  | "projects:create"
  | "projects:edit"
  | "projects:delete"
  | "projects:view";

const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
  owner: [
    "org:manage", "org:billing", "org:delete",
    "members:invite", "members:remove", "members:change_role",
    "projects:create", "projects:edit", "projects:delete", "projects:view",
  ],
  admin: [
    "org:manage",
    "members:invite", "members:remove",
    "projects:create", "projects:edit", "projects:delete", "projects:view",
  ],
  member: [
    "projects:create", "projects:edit", "projects:view",
  ],
  viewer: [
    "projects:view",
  ],
};

export function hasPermission(role: Role, permission: Permission): boolean {
  return ROLE_PERMISSIONS[role].includes(permission);
}

export function requirePermission(role: Role, permission: Permission): void {
  if (!hasPermission(role, permission)) {
    throw new Error(`Insufficient permissions: ${permission} requires ${role}`);
  }
}

Level 1: Database (RLS policies). The SQL policies I showed earlier enforce that users can only see data from organizations they belong to. This is the foundation — even if the application logic has a bug, the database will not leak data across tenants.

Level 2: Server Actions. Every mutation checks the user's role before executing. The requirePermission function throws if the role does not have the required permission.

typescript
export async function deleteProject(projectId: string) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const { data: membership } = await supabase
    .from("memberships")
    .select("role")
    .eq("user_id", user.id)
    .single();

  requirePermission(membership!.role as Role, "projects:delete");

  await supabase.from("projects").delete().eq("id", projectId);
  revalidatePath("/dashboard/projects");
}

Level 3: UI. Components conditionally render based on the user's role. The billing page is only visible to owners. The delete button only appears for admins and owners. This is a UX layer, not a security layer — the real enforcement is in the database and server actions.

typescript
// components/dashboard/delete-button.tsx
"use client";

import { useOrg } from "@/components/providers/org-provider";
import { hasPermission } from "@/lib/permissions";

export function DeleteProjectButton({ projectId }: { projectId: string }) {
  const { role } = useOrg();

  if (!hasPermission(role, "projects:delete")) return null;

  return (
    <button onClick={() => deleteProject(projectId)}>
      Delete
    </button>
  );
}

Three layers of defense. If any one fails, the other two catch it. This is how production RBAC works.


API Design for SaaS

Every SaaS needs an API — if not for third-party integrations today, then for your mobile app tomorrow. I design APIs from the start even if the first version is only consumed by the Next.js frontend.

The pattern I use is Next.js Route Handlers with Zod validation, consistent error responses, and rate limiting.

typescript
// app/api/v1/projects/route.ts
import { createClient } from "@/lib/supabase/server";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

const CreateProjectSchema = z.object({
  name: z.string().min(1).max(200),
  description: z.string().max(1000).optional(),
});

export async function GET() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json(
      { code: "UNAUTHORIZED", message: "Authentication required" },
      { status: 401 }
    );
  }

  const { data: projects, error } = await supabase
    .from("projects")
    .select("id, name, description, status, created_at")
    .order("created_at", { ascending: false });

  if (error) {
    return NextResponse.json(
      { code: "INTERNAL_ERROR", message: "Failed to fetch projects" },
      { status: 500 }
    );
  }

  return NextResponse.json({ data: projects });
}

export async function POST(request: NextRequest) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json(
      { code: "UNAUTHORIZED", message: "Authentication required" },
      { status: 401 }
    );
  }

  const body = await request.json();
  const parsed = CreateProjectSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      {
        code: "VALIDATION_ERROR",
        message: "Invalid input",
        details: parsed.error.flatten().fieldErrors,
      },
      { status: 400 }
    );
  }

  const { data: project, error } = await supabase
    .from("projects")
    .insert({
      name: parsed.data.name,
      description: parsed.data.description,
      created_by: user.id,
    })
    .select()
    .single();

  if (error) {
    return NextResponse.json(
      { code: "INTERNAL_ERROR", message: "Failed to create project" },
      { status: 500 }
    );
  }

  return NextResponse.json({ data: project }, { status: 201 });
}

Every response follows the same shape: { data } for success, { code, message, details? } for errors. Consistent error formats save hours of debugging on the frontend. The code is machine-readable, the message is human-readable, and details provides field-level validation errors when relevant.

For rate limiting, I use Vercel's built-in edge middleware or Upstash Redis for more granular control. A basic implementation with Upstash:

typescript
// middleware.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(60, "1 m"),
});

export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/api/")) {
    const ip = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
    const { success, remaining } = await ratelimit.limit(ip);

    if (!success) {
      return NextResponse.json(
        { code: "RATE_LIMITED", message: "Too many requests" },
        { status: 429, headers: { "Retry-After": "60" } }
      );
    }

    const response = NextResponse.next();
    response.headers.set("X-RateLimit-Remaining", remaining.toString());
    return response;
  }

  return NextResponse.next();
}

Version your API from day one. Put everything under /api/v1/. When you need breaking changes, create /api/v2/ and deprecate the old version with a 6-month sunset window. Your future self will thank you.


Email with Resend

Every SaaS sends email — welcome messages, password resets, billing receipts, team invitations. I use Resend because it lets me write email templates as React components, the API is dead simple, and the deliverability is excellent.

typescript
// lib/email.ts
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

interface SendEmailOptions {
  to: string;
  subject: string;
  react: React.ReactElement;
}

export async function sendEmail({ to, subject, react }: SendEmailOptions) {
  const { error } = await resend.emails.send({
    from: "YourSaaS <notifications@yoursaas.com>",
    to,
    subject,
    react,
  });

  if (error) {
    throw new Error(`Failed to send email: ${error.message}`);
  }
}

A team invitation email template:

typescript
// emails/team-invite.tsx
import {
  Body, Container, Head, Heading, Html,
  Link, Preview, Section, Text,
} from "@react-email/components";

interface TeamInviteEmailProps {
  inviterName: string;
  organizationName: string;
  inviteUrl: string;
}

export function TeamInviteEmail({
  inviterName,
  organizationName,
  inviteUrl,
}: TeamInviteEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>
        {inviterName} invited you to join {organizationName}
      </Preview>
      <Body style={{ fontFamily: "Inter, sans-serif" }}>
        <Container>
          <Heading>You have been invited</Heading>
          <Text>
            {inviterName} has invited you to join{" "}
            <strong>{organizationName}</strong>.
          </Text>
          <Section style={{ textAlign: "center", margin: "32px 0" }}>
            <Link
              href={inviteUrl}
              style={{
                backgroundColor: "#F7931A",
                color: "#ffffff",
                padding: "12px 24px",
                borderRadius: "6px",
                textDecoration: "none",
                fontWeight: 600,
              }}
            >
              Accept Invitation
            </Link>
          </Section>
          <Text style={{ color: "#6B7FA3", fontSize: "14px" }}>
            This invitation expires in 7 days.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

The emails I send in every SaaS project: welcome (on signup), team invitation, password reset, subscription confirmation, payment failed, trial ending (3 days before expiry), and weekly usage digest. That covers 95% of transactional email needs.


Deployment on Vercel

Deploying a Next.js SaaS on Vercel is the easiest part of the stack. Connect your GitHub repo, set environment variables, and push. But there are patterns that separate a hobby deployment from a production one.

Environment variables. I maintain three sets: development (local .env.local), preview (Vercel preview deployments), and production. Stripe test keys for preview, live keys for production. Separate Supabase projects for staging and production.

Preview deployments. Every pull request gets its own URL. I use this for QA — the client can test features on a real deployment before they hit production. Vercel does this automatically.

Edge middleware. Auth checks, rate limiting, and geo-routing run at the edge, close to the user. This keeps the main application fast because unauthorized requests never reach your serverless functions.

Monitoring. Vercel Analytics for web vitals, Vercel Speed Insights for performance, and Sentry for error tracking. I configure Sentry to capture unhandled errors in both server and client components.

The deployment pipeline I use:

feature branch → pull request → preview deployment → QA review
                                                        ↓
                                          merge to main → production deploy
                                                        ↓
                                          vercel checks → lighthouse audit

A vercel.json for production hardening:

json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        {
          "key": "Strict-Transport-Security",
          "value": "max-age=31536000; includeSubDomains"
        },
        {
          "key": "Permissions-Policy",
          "value": "camera=(), microphone=(), geolocation=()"
        }
      ]
    }
  ]
}

Security headers are non-negotiable. Every SaaS I deploy includes the full set — HSTS, X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy. It takes five minutes to set up and prevents entire categories of attacks.


Cost Breakdown — What $8K-$35K Gets You

I get asked about SaaS development costs in almost every discovery call. Here is an honest breakdown of what I charge and what the client gets at each tier.

Tier 1: MVP SaaS — $8,000 to $12,000

  • Auth (email + Google OAuth)
  • Multi-tenant database with RLS
  • One subscription tier with Stripe
  • Dashboard with 3-5 pages
  • Basic RBAC (owner/member)
  • Transactional email (welcome, password reset)
  • Vercel deployment with CI/CD
  • Timeline: 3-4 weeks

This is the "prove the business works" build. I use it for founders validating a SaaS idea. No bells, no whistles, but the architecture is production-grade. You will not need to rewrite it when you scale.

Tier 2: Growth SaaS — $15,000 to $25,000

Everything in Tier 1, plus:

  • Multiple subscription tiers with annual discounts
  • Full RBAC (owner/admin/member/viewer)
  • Team invitations and management
  • Customer portal via Stripe
  • Onboarding flow
  • Dashboard with 8-12 pages
  • API with versioning and rate limiting
  • File uploads with Supabase Storage
  • Email sequences (trial ending, payment failed)
  • Monitoring and error tracking
  • Timeline: 5-8 weeks

This is the tier most funded startups need. The product is complete enough to charge real money and support a team of users.

Tier 3: Scale SaaS — $25,000 to $35,000

Everything in Tier 2, plus:

  • Custom domain support per tenant
  • Advanced analytics dashboard
  • Webhook system for integrations
  • API key management for programmatic access
  • Audit logging
  • Admin panel for your operations team
  • Custom reporting
  • Performance optimization (sub-2s LCP)
  • Load testing and scaling review
  • Timeline: 8-12 weeks

This is the "we have product-market fit and need to scale" build. Architecture reviewed for 10,000+ users. Performance optimized. Production hardened.

Monthly infrastructure costs after launch:

ServiceFree tierGrowthScale
Supabase$0$25$25-75
Vercel$0$20$20
Stripe2.9% + $0.30/txSameSame
Resend$0 (3K emails)$20$20
Upstash Redis$0$10$10
Sentry$0$0$26
Total$0~$75/mo~$175/mo

Compare that to the $500-$2,000 per month you would pay for AWS infrastructure with similar capabilities. The managed service stack is not just easier — it is cheaper until you reach serious scale.


Key Takeaways

  1. Start with the schema. Your database is the foundation. Get multi-tenancy right with organizations, profiles, and memberships. Everything else builds on top.
  1. RLS is your best friend. Row Level Security means the database enforces tenant isolation. Application bugs cannot leak data across organizations. Use it on every table.
  1. Webhooks are the source of truth. Never trust client-side redirects for subscription state. Stripe webhooks tell you what actually happened. Build your billing logic around them.
  1. Server Components for dashboards. Fetch data on the server, pass it to interactive client components. No loading spinners on initial render. No fetch waterfalls.
  1. Three layers of RBAC. Database policies, server action checks, and UI conditionals. If any one layer fails, the other two catch it.
  1. Version your API from day one. You will thank yourself when you need breaking changes and existing integrations depend on the old format.
  1. Security headers take five minutes. HSTS, X-Frame-Options, CSP, Referrer-Policy — they prevent entire categories of attacks. There is no excuse to skip them.
  1. The stack costs almost nothing to run. Supabase + Vercel + Stripe + Resend = under $75 per month for a growth-stage SaaS. Infrastructure cost is not the bottleneck. Building the right product is.

If you are planning a SaaS build and want to talk through architecture, scope, or pricing, book a discovery call. I will tell you honestly whether this is a $10K project or a $30K project — and whether you should build it at all.


About the Author

Uvin Vindula is a full-stack developer and Web3 engineer based in Sri Lanka, working with clients across the UK, US, and EU. He builds SaaS products, dashboards, and web applications using Next.js, Supabase, and modern TypeScript. His work is at iamuvin.com and his code is at github.com/iamuvin.

Building a SaaS product? See what I offer or get in touch to discuss your project.

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.