DEV Community

Cover image for Build an Internal CRM on Supabase: A Weekend-Scale Guide
Iurii Rogulia
Iurii Rogulia

Posted on • Originally published at iurii.rogulia.fi

Build an Internal CRM on Supabase: A Weekend-Scale Guide

Your business runs on a process that doesn't fit neatly into Contacts → Companies → Deals. Maybe you track project-based client relationships with custom stages, or B2B accounts with multiple contacts per deal and non-linear pipelines. So you spend three hours bending HubSpot to your shape, and it still isn't right. Or the per-seat invoice arrived and you did the math: you're paying more for the CRM than for your entire hosting stack combined, and half the seats barely log in.

This is the point where "we could just build it" starts to sound reasonable.

This article is the third in a series. The first article compared open-source CRM options (Twenty, EspoCRM, NocoDB, SuiteCRM) and the DIY-on-Postgres path. The second article covered what HubSpot migrations actually cost and what breaks in week two. This one is the practical how-to for the custom build: what Supabase gives you out of the box, how to design the schema, how to wire up a Next.js frontend over it, and — honestly — where the "weekend" claim stops being accurate.

When Custom Actually Makes Sense

The headless CRM comparison article is honest about this: a custom build is not the right answer for a conventional B2B sales pipeline. If Contacts/Companies/Deals/Activities describes your data model in full, you're better off with Twenty or EspoCRM. The engineering cost of building and maintaining a custom CRM is real, and it doesn't vanish because the tooling is good.

The custom build earns its place in a few specific situations:

Your data already lives in Postgres. You're building a SaaS product and you want CRM-style views over data your application already owns. Adding a UI and some workflow logic on top of your existing schema is a different problem than building a CRM from scratch. Supabase makes the former much simpler.

Your domain model is genuinely unusual. In pikkuna.fi, the order management and customer workflow layer is essentially a custom CRM — because multi-market B2B e-commerce with 30 languages, complex automation, and non-standard fulfillment pipelines doesn't map onto Contacts/Deals without contortion. When the standard object model requires so much mapping that you're maintaining a translation layer anyway, owning the schema directly is simpler.

You need narrow, specific functionality. Pipeline tracking, a few custom fields, activity logging, team assignment, and maybe a simple dashboard. If you're confident it will stay narrow and someone will own it, the custom route is reasonable.

The feature set you need is bounded. The failure mode I've seen in internal CRMs isn't that they fail to launch — it's that they become second products without product management. Custom fields proliferate. Workflow shortcuts get coded in by whoever was free that week. After eighteen months, no one can confidently change anything. If you build it, someone needs to own its scope, not just its code.

If none of those apply — conventional pipeline, standard object model, team growing fast — look at Twenty or EspoCRM first. They'll be cheaper over a two-year horizon.

What Supabase Gives You for Free

The reason this is a "weekend" story (with caveats) is that Supabase eliminates most of the infrastructure that makes a custom backend expensive to build.

Postgres — the actual database, fully managed, with all of the SQL features that matter: Row-Level Security, triggers, functions, constraints, foreign keys, jsonb. You're not using a proprietary data layer; you're writing real SQL that you can move off Supabase if you ever need to.

Auth — email/password, magic link, OAuth providers (Google, GitHub, etc.), and JWT-based sessions that integrate with RLS policies. You don't write an auth system.

Auto-generated REST and GraphQL APIs — Supabase's PostgREST layer exposes your schema as a REST API automatically. For a team CRM, you can talk directly to the database through @supabase/supabase-js without writing a single API endpoint. For more complex business logic, you can add Supabase Edge Functions (Deno-based serverless functions) or point Next.js Server Actions directly at the database via the Supabase client.

Row-Level Security — RLS policies enforce who can see what, at the database layer. This matters for a team CRM: you want sales reps to see their own deals by default, managers to see their team's deals, and admins to see everything — without those rules living only in application code where someone can accidentally bypass them.

Realtime — Supabase wraps Postgres's logical replication to broadcast row changes to connected clients. For a deals board, this means deal stage changes appear immediately for everyone without polling.

Storage — file uploads (attachments on notes, documents on deals) backed by S3-compatible storage with its own access policies.

The free tier covers a small team's CRM comfortably — but "free forever" depends on usage patterns, not headcount. What pushes you onto the Pro tier ($25/month) is rarely the number of users; it's realtime fanout, storage and attachment churn, query volume, and backups. I've seen eight people run on free tier for a year, and I've seen five busy sales reps hit realtime and quota limits in a month. Track those metrics, not the seat count.

Schema Design

Here's the schema I'd use for a focused internal CRM. It covers the standard entities without over-engineering.

-- core entities
CREATE TABLE companies (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        TEXT NOT NULL,
  domain      TEXT,
  industry    TEXT,
  notes       TEXT,
  created_by  UUID NOT NULL REFERENCES auth.users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE contacts (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  company_id  UUID REFERENCES companies(id) ON DELETE SET NULL,
  first_name  TEXT NOT NULL,
  last_name   TEXT NOT NULL,
  email       TEXT,
  phone       TEXT,
  title       TEXT,
  created_by  UUID NOT NULL REFERENCES auth.users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- deals pipeline
CREATE TYPE deal_stage AS ENUM (
  'prospect', 'qualified', 'proposal', 'negotiation', 'won', 'lost'
);

CREATE TABLE deals (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title        TEXT NOT NULL,
  company_id   UUID REFERENCES companies(id) ON DELETE SET NULL,
  contact_id   UUID REFERENCES contacts(id) ON DELETE SET NULL,
  stage        deal_stage NOT NULL DEFAULT 'prospect',
  value        NUMERIC(12, 2),
  currency     TEXT NOT NULL DEFAULT 'EUR',
  close_date   DATE,
  owner_id     UUID NOT NULL REFERENCES auth.users(id),
  created_by   UUID NOT NULL REFERENCES auth.users(id),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- activity log (calls, emails, meetings)
CREATE TYPE activity_type AS ENUM ('call', 'email', 'meeting', 'task', 'note');

CREATE TABLE activities (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  deal_id     UUID REFERENCES deals(id) ON DELETE CASCADE,
  contact_id  UUID REFERENCES contacts(id) ON DELETE SET NULL,
  type        activity_type NOT NULL,
  subject     TEXT NOT NULL,
  body        TEXT,
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  created_by  UUID NOT NULL REFERENCES auth.users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- performance indexes
CREATE INDEX idx_deals_owner    ON deals(owner_id);
CREATE INDEX idx_deals_stage    ON deals(stage);
CREATE INDEX idx_deals_company  ON deals(company_id);
CREATE INDEX idx_activities_deal ON activities(deal_id);
CREATE INDEX idx_contacts_company ON contacts(company_id);
Enter fullscreen mode Exit fullscreen mode

The deal_stage enum is intentional. PostgreSQL enforces valid values at the storage layer — you can't accidentally insert a typo like "prospact". When you need to add a stage later, ALTER TYPE deal_stage ADD VALUE 'trial' works without rewriting the column. One honest caveat: ADD VALUE can't run inside a transaction block in older Postgres, and enum changes are awkward to reconcile across multiple environments in CI/CD. If your pipeline stages change often, a stages lookup table with a foreign key is more migration-friendly — just less self-documenting.

Three Things to Add Before This Is Real

The schema above is the starting point, not the finish line. Three additions matter more than they look:

Auto-update updated_at with a trigger, not application code. A DEFAULT NOW() only fires on insert. If you set updated_at by hand in every UPDATE (as the Server Action later does), you'll eventually forget one. Push it into the database:

CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_deals_updated_at
  BEFORE UPDATE ON deals
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- repeat for companies, contacts
Enter fullscreen mode Exit fullscreen mode

Soft delete, not hard delete. In a CRM, deletes are almost always mistakes you want back. Add deleted_at TIMESTAMPTZ to the core tables and keep an undo path instead of a support incident. The catch: the column does nothing on its own — your SELECT policies have to exclude soft-deleted rows, or they'll keep showing up everywhere:

CREATE POLICY deals_select ON deals
  FOR SELECT
  USING (
    deleted_at IS NULL
    AND (
      owner_id = auth.uid()
      OR EXISTS (
        SELECT 1 FROM team_members
        WHERE team_members.user_id = auth.uid()
          AND team_members.role IN ('manager', 'admin')
      )
    )
  );
Enter fullscreen mode Exit fullscreen mode

Reserve hard DELETE for GDPR erasure, not a misclick.

An audit log. For a CRM, "who changed this deal's value, and when?" is a question that will be asked — usually during a dispute. A single append-only table written from a trigger covers it cheaply:

CREATE TABLE audit_log (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  table_name TEXT NOT NULL,
  row_id     UUID NOT NULL,
  actor      UUID,                 -- auth.uid() at write time
  action     TEXT NOT NULL,        -- 'insert' | 'update' | 'delete'
  old_data   JSONB,
  new_data   JSONB,
  at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE OR REPLACE FUNCTION write_audit_log()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO audit_log (table_name, row_id, actor, action, old_data, new_data)
  VALUES (
    TG_TABLE_NAME,
    COALESCE(NEW.id, OLD.id),
    auth.uid(),           -- the authenticated user making the change
    lower(TG_OP),
    to_jsonb(OLD),
    to_jsonb(NEW)
  );
  RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_deals_audit
  AFTER INSERT OR UPDATE OR DELETE ON deals
  FOR EACH ROW EXECUTE FUNCTION write_audit_log();
Enter fullscreen mode Exit fullscreen mode

Attach this trigger to your business tables (deals, contacts, companies) — but never to audit_log itself, or each write will recursively log its own logging. I add this pattern to every business system I build — it's cheap insurance against a question you can't answer later.

Row-Level Security Policies

RLS is where Supabase's architecture pays off for a team CRM. You define access rules once in SQL, and they hold regardless of which path the query comes through — direct Supabase client calls, Server Actions, or API routes.

First, define a team roles table. Rather than baking role logic into every policy, store roles separately:

CREATE TYPE team_role AS ENUM ('admin', 'manager', 'rep');

CREATE TABLE team_members (
  user_id   UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  role      team_role NOT NULL DEFAULT 'rep',
  manager_id UUID REFERENCES auth.users(id),  -- for manager hierarchy
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

Then enable and write the policies:

-- companies: all team members can read; reps can create; managers and admins can update/delete
ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
ALTER TABLE companies FORCE ROW LEVEL SECURITY;

CREATE POLICY companies_select ON companies
  FOR SELECT
  USING (
    EXISTS (SELECT 1 FROM team_members WHERE user_id = auth.uid())
  );

CREATE POLICY companies_insert ON companies
  FOR INSERT
  WITH CHECK (
    EXISTS (SELECT 1 FROM team_members WHERE user_id = auth.uid())
  );

CREATE POLICY companies_update ON companies
  FOR UPDATE
  USING (
    EXISTS (
      SELECT 1 FROM team_members
      WHERE user_id = auth.uid() AND role IN ('admin', 'manager')
    )
  );

-- deals: reps see their own deals; managers see their team's deals; admins see all
ALTER TABLE deals ENABLE ROW LEVEL SECURITY;
ALTER TABLE deals FORCE ROW LEVEL SECURITY;

CREATE POLICY deals_select ON deals
  FOR SELECT
  USING (
    owner_id = auth.uid()
    OR EXISTS (
      SELECT 1 FROM team_members
      WHERE user_id = auth.uid() AND role = 'admin'
    )
    OR EXISTS (
      SELECT 1 FROM team_members tm
      JOIN team_members rep ON rep.manager_id = tm.user_id
      WHERE tm.user_id = auth.uid()
        AND rep.user_id = deals.owner_id
        AND tm.role = 'manager'
    )
  );

CREATE POLICY deals_insert ON deals
  FOR INSERT
  WITH CHECK (
    owner_id = auth.uid()
    AND EXISTS (SELECT 1 FROM team_members WHERE user_id = auth.uid())
  );

CREATE POLICY deals_update ON deals
  FOR UPDATE
  USING (
    owner_id = auth.uid()
    OR EXISTS (
      SELECT 1 FROM team_members WHERE user_id = auth.uid() AND role IN ('admin', 'manager')
    )
  );
Enter fullscreen mode Exit fullscreen mode

One thing to be careful about: FORCE ROW LEVEL SECURITY prevents the table owner (the Postgres role that created the table) from bypassing policies. It does not make the Supabase service role behave like a normal user — the service role bypasses RLS by design and is meant for trusted server-side operations only. Treat the service role key like a root credential, never expose it to the client, and lean on FORCE RLS for the owner-bypass case. The multi-tenant-saas-schema article goes into this in more detail — the same rule applies here.

For server-side operations that need to bypass RLS (admin dashboards, background jobs), use the Supabase service role key, not the anon key. Keep the service role key server-side only — never expose it to the browser.

Next.js Frontend Over supabase-js

The frontend is a Next.js App Router application. Here's the pattern I use for data fetching in Server Components:

// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/lib/database.types";

export function createSupabaseServerClient() {
  const cookieStore = cookies();
  return createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Server Component — cookies() is read-only here; ignore
          }
        },
      },
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

A Server Component that fetches deals for the pipeline view:

// app/pipeline/page.tsx
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { PipelineBoard } from "@/components/pipeline-board";

export default async function PipelinePage() {
  const supabase = createSupabaseServerClient();
  const { data: { user } } = await supabase.auth.getUser();

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

  const { data: deals, error } = await supabase
    .from("deals")
    .select(`
      id,
      title,
      stage,
      value,
      currency,
      close_date,
      owner_id,
      companies ( id, name ),
      contacts ( id, first_name, last_name )
    `)
    .order("created_at", { ascending: false });

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

  return <PipelineBoard deals={deals ?? []} />;
}
Enter fullscreen mode Exit fullscreen mode

RLS handles the filtering. The query doesn't need a WHERE owner_id = user.id clause — the policy enforces it. Admins get all deals, managers get their team's deals, reps get their own. The same query, different result sets, no application-level branching.

Realtime Deal Updates

For a shared pipeline board where multiple reps are moving deals between stages, realtime subscriptions keep everyone in sync without polling.

// components/pipeline-board.tsx
"use client";

import { useEffect, useState } from "react";
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/lib/database.types";

type Deal = Database["public"]["Tables"]["deals"]["Row"] & {
  companies: { id: string; name: string } | null;
  contacts: { id: string; first_name: string; last_name: string } | null;
};

export function PipelineBoard({ deals: initialDeals }: { deals: Deal[] }) {
  const [deals, setDeals] = useState<Deal[]>(initialDeals);

  const supabase = createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );

  useEffect(() => {
    const channel = supabase
      .channel("pipeline-deals")
      .on(
        "postgres_changes",
        {
          event: "*",        // INSERT, UPDATE, DELETE
          schema: "public",
          table: "deals",
        },
        (payload) => {
          if (payload.eventType === "UPDATE") {
            setDeals((prev) =>
              prev.map((d) =>
                d.id === payload.new.id ? { ...d, ...payload.new } : d
              )
            );
          } else if (payload.eventType === "INSERT") {
            // Re-fetch to get the joined company/contact data
            // Realtime payloads don't include joined relations
            fetchAndAddDeal(payload.new.id);
          } else if (payload.eventType === "DELETE") {
            setDeals((prev) => prev.filter((d) => d.id !== payload.old.id));
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, []);

  async function fetchAndAddDeal(dealId: string) {
    const { data } = await supabase
      .from("deals")
      .select(`id, title, stage, value, currency, close_date, owner_id, companies(id, name), contacts(id, first_name, last_name)`)
      .eq("id", dealId)
      .single();

    if (data) {
      setDeals((prev) => [data as Deal, ...prev]);
    }
  }

  // render your Kanban board here
  return (
    <div className="grid grid-cols-6 gap-4">
      {/* stage columns, deal cards */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

One note on realtime and RLS: Supabase's realtime server currently evaluates RLS at subscription time, not at event time. A deal that changes ownership after subscription might not be filtered correctly until the subscription is re-established. For a team CRM with stable ownership, this is fine in practice. For more complex delegation scenarios, be aware of the limitation.

Moving Stages: A Server Action

Deal stage changes are the core write operation. A Server Action keeps this server-side with type safety:

// app/actions/deals.ts
"use server";

import { createSupabaseServerClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const DealStage = z.enum(["prospect", "qualified", "proposal", "negotiation", "won", "lost"]);

export async function moveDealStage(dealId: string, newStage: z.infer<typeof DealStage>) {
  const stage = DealStage.parse(newStage); // throws on invalid value

  const supabase = createSupabaseServerClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    throw new Error("Unauthorized");
  }

  const { error } = await supabase
    .from("deals")
    .update({ stage }) // updated_at is set by the trigger, not by hand
    .eq("id", dealId);
  // No .eq("owner_id", user.id) needed — RLS enforces write permissions

  if (error) {
    throw new Error(`Failed to update deal stage: ${error.message}`);
  }

  revalidatePath("/pipeline");
}
Enter fullscreen mode Exit fullscreen mode

The RLS UPDATE policy handles authorization. If a rep tries to update a deal they don't own, the update returns zero rows (Supabase doesn't expose RLS violations as errors by default). For explicit error feedback, you can verify the row exists with select before updating.

slug="mvp-development"
text="Building an internal tool or custom CRM from scratch? I scope and build these — schema design, auth, integrations, deployment — without the overengineering that makes them hard to maintain."
/>

What "A Weekend" Actually Means

Let me be precise, because the title is doing some marketing the rest of this article shouldn't. A weekend gets you a working prototype you can demo and start entering real data into:

  • Schema defined and migrated
  • Auth working (Supabase handles the heavy lifting)
  • A pipeline board, a contacts list, and a deals form
  • RLS policies tested against the roles you have
  • Deployed to Vercel or your own host

A weekend does not get you a production system the whole team relies on. Hardening RLS for every role and edge case, the triggers and audit log above, soft-delete and recovery flows, error states, data-entry validation, the inevitable "can we also track X" requests — that's a focused 1–3 weeks of engineering, not an afternoon. Anyone quoting you a true production CRM "by Monday" is selling the same fantasy this series keeps warning about. The weekend is real; it just produces a prototype, not a product.

None of this is a weekend item either:

Email integration. If you want emails sent from the CRM, or replies tracked back to deals, you need Resend or Postmark for sending and either a custom inbound-email webhook or a forwarding rule for replies. That's a separate project, not an afternoon.

Calendar sync. Google Calendar or Outlook integration for scheduling meetings requires OAuth flows and API work. Plan separately.

Complex reporting. A simple "deals by stage" count is fine. Forecasting, pipeline velocity, conversion rates — that requires query logic and chart components that take time to build right.

Mobile experience. Supabase's realtime and REST APIs work fine on mobile, but building a polished mobile UI in Next.js is a different effort than a desktop dashboard.

Automated workflows. If you want a deal stage change to trigger an email sequence, a Slack notification, or a Zoho CRM update, you need a job queue (BullMQ, pg-boss) or a webhook layer. This is exactly the integration work covered in shipping CRM automation.

The weekend gets you the data layer and a working UI. The surrounding system — email, notifications, integrations — is where the real work lives.

The Organizational Risk (Again)

I wrote about this in the headless CRM comparison, and it's worth repeating because it's the part that bites teams most often.

The technical risk of building on Supabase is low. The organizational risk is not. An internal CRM built without a named product owner gradually accumulates:

  • Custom fields nobody can explain
  • Stage logic that reflects one deal from 2025 and was never cleaned up
  • A webhook to a contractor's system that nobody has the credentials for
  • Business logic that lives in the CRM schema instead of being documented anywhere

After eighteen months, the system knows things about your process that you don't. You can't change anything without risking something you don't understand.

The fix isn't technical. Someone needs to own the scope of this CRM, not just the code. Not "make sure it's running" — actively decide what goes in and what doesn't. If you can't name that person before you start building, that's the first problem to solve.

When to Step Off Supabase

Supabase is a good fit when your usage is moderate, your data model is stable, and your query patterns are straightforward. A few signals that you're growing past it:

Connection pooling pressure. Supabase uses PgBouncer. If you're running many concurrent connections (many users, background jobs, realtime subscriptions all at once), you'll hit connection limits. The paid tiers give you more, but at some point a dedicated Postgres instance with better pooling configuration becomes the right call.

Complex migration needs. Supabase's migration tooling has improved significantly, but if you're running schema-per-tenant isolation (covered in the multi-tenant SaaS schema article) or frequent migrations across large tables, managing that in Supabase's dashboard becomes uncomfortable. A direct Postgres connection with proper migration tooling (Drizzle ORM, Flyway) gives you more control.

Vendor dependency concerns. Supabase is open-source and you can self-host it. But the managed service is the easy path, and if you ever need to move off it, the migration is real work. If long-term data sovereignty is a hard requirement, design for portability from the start: use standard SQL where possible, and avoid Supabase-specific features that don't have open-source equivalents.

What to Build vs What to Buy

The right answer for most small teams is not "build a CRM on Supabase" or "pay for HubSpot" — it's a clearer question about what you actually need.

If the pipeline tracking is standard and your integrations are conventional, Twenty or EspoCRM will cost less over two years. If you've already migrated off HubSpot and the integration layer is now the problem, a custom system might not be the solution — the integration might be.

The custom Supabase build makes sense when the data already lives in your database, when the domain is genuinely unusual, and when someone can own the scope. Under those conditions, a focused weekend sprint gets you somewhere real, and Supabase's infrastructure means you're not rebuilding auth, file storage, or realtime from scratch.

Outside those conditions, the weekend turns into a quarter, and the CRM turns into a second product nobody signed up to maintain.

items={[
{
q: "Can I build a team CRM on Supabase's free tier?",
a: "Yes — Supabase's free tier includes Postgres, auth, PostgREST, realtime, and storage, and it covers a small team with moderate usage. But what forces the Pro tier ($25/month) is usually usage pattern, not headcount: realtime fanout, storage and attachment churn, query volume, and backups. A quiet team of 15 can stay on free tier; a busy team of 5 with heavy realtime and uploads may not. Watch those metrics rather than the seat count.",
},
{
q: "Do I need to write backend API routes for a Supabase CRM?",
a: "Not for standard CRUD. Supabase's PostgREST layer exposes your schema as a REST API automatically, and @supabase/supabase-js lets you query it with type safety from Next.js Server Components or Server Actions. For complex business logic — stage transition rules, notification triggers, multi-step workflows — Next.js Server Actions calling the Supabase client directly are the cleaner pattern.",
},
{
q: "How does Row-Level Security work with Supabase?",
a: "You define RLS policies in SQL on your Postgres tables. Supabase maps authenticated users to auth.uid(), which your policies can reference. When a query comes in via the Supabase client with the anon key, it runs as the authenticated user with RLS enforced. When it comes in via the service role key (server-side only), RLS is bypassed by design — so guard that key. The key practice is enabling RLS on every exposed table and testing your policies under real roles. FORCE ROW LEVEL SECURITY helps prevent table-owner bypass, but it does not replace careful service-role handling.",
},
{
q: "What if my CRM needs email integration and automated workflows?",
a: "Email sending (outbound) is straightforward — use Resend or Postmark from a Server Action or Edge Function. Reply tracking and automated sequences require more infrastructure: an inbound email webhook, a job queue (BullMQ or pg-boss), and trigger logic. This is a separate project from the CRM itself. Build the CRM core first, add automation as a second phase once the data model is stable.",
},
{
q: "When should I use a headless CRM instead of building on Supabase?",
a: "When your pipeline is conventional (Contacts, Companies, Deals, Activities), your team is growing, and you need a polished mobile experience, email integration, or complex reporting out of the box. Twenty and EspoCRM cover standard sales workflows without the maintenance cost of a custom build. The custom route makes sense when your data already lives in Postgres, your domain is non-standard, or the feature set is narrow enough to keep bounded.",
},
]}
/>

Top comments (0)