← All templates
    SaaS App
    Free download

    SaaS Starter Template for Lovable

    Multi-tenant workspaces, three roles, and a working invite flow, with the row-level security that makes tenancy real rather than cosmetic.

    Stack: React + Vite, TypeScript, Tailwind CSS, shadcn/ui, Supabase

    What is in the download

    • A 400-word build prompt with numbered priority, an explicit build order and safeguards
    • A Supabase schema: profiles, workspaces, workspace_members and workspace_invites
    • Row-level security on every table, deny by default, with owner, admin and member roles
    • Two SECURITY DEFINER helpers that avoid the recursive-policy trap on a membership table
    • An invite acceptance function, because the invitee cannot select the row they are accepting
    • Six verification queries that prove a member cannot promote themselves to owner

    What it is not

    • Not a pre-built app you clone and run. There is no repository and no node_modules.
    • Not a design file or a theme. Lovable generates the interface from the prompt.
    • Not a substitute for reading the SQL. You are running it against your own database.

    Setup order

    Running these out of order is the usual reason a template appears broken. The schema comes before the prompt, because the prompt references the table names it creates.

    1. Run schema.sql in the Supabase SQL editor

      Before prompting Lovable. The prompt references these table names, so building first means rework.

    2. Run verify.sql as an ordinary signed-in user

      Not as service_role, which bypasses every policy and will make a broken schema look correct.

    3. Paste prompt.md into Lovable

      Unedited on the first pass, so you can see what the structure produces before changing it.

    4. Replace the audience line

      The Context section names a small team adopting a tool. Changing that one line changes which features Lovable prioritises more than anything else in the prompt.

    Things that catch people out

    • The trigger that makes a workspace creator an owner is load-bearing. Without it the creator cannot read the workspace they just made, because the read policy requires membership. This is the most common way this schema appears broken when it is not.
    • The invite table has no anonymous read policy on purpose. Anyone who can select from it can enumerate pending invite tokens for every workspace.
    • Lovable will sometimes generate a client-side role check and nothing else. The policies here mean a missing UI check is a cosmetic bug rather than a data breach.

    prompt.md

    Download

    The build prompt

    # Context
    Build a multi-tenant SaaS application where users belong to workspaces and a workspace has
    its own members, roles and plan state. The target user is a small team adopting a tool
    together, so the first thing that must work is inviting a colleague and having them land in
    the same workspace.
    
    ## Core Features (Priority Order)
    1. Email and password authentication with session persistence across refresh
    2. Workspace creation on first sign-in, with the creator as owner
    3. Workspace member list with three roles: owner, admin, member
    4. Invite by email, producing a tokenised link that joins the invitee to the same workspace
    5. Role-gated UI: only owner and admin can invite, change roles or remove members
    6. Plan state visible in the app (free or paid) with an upgrade call to action
    
    ## Visual Style
    - Professional SaaS aesthetic, spacing on a 4px grid, 8px corner radius
    - shadcn/ui: Card for panels, Table for the member list, Badge for role and plan,
      Avatar for members, Dialog for the invite flow, Button for actions
    - One accent colour used only for primary actions and active navigation
    
    ## Technical Requirements
    - Breakpoints sm 640px, md 768px, lg 1024px, mobile first
    - Loading skeletons for the member list, not spinners
    - Empty state for a workspace with one member, prompting the first invite
    - Error state for an expired or already-used invite token
    - Form validation with inline messages, never an alert
    - Dark mode via Tailwind's dark: prefix
    
    ## Implementation Strategy
    Build in this order and do not reorder it:
    1. Auth and the protected route wrapper
    2. The role-checking helper, before any UI that depends on a role
    3. Workspace and member reads against the schema in schema.sql
    4. The invite write path, including the expired-token case
    5. Plan display last, because it depends on workspace state existing
    
    ## Safe-Guard Instructions
    - Enforce entitlement in application code as well as in the database. Do not assume a
      database function named like a permission check actually enforces anything.
    - Never select from workspace_members without a workspace filter.
    - Treat role as server-derived. Do not accept a role from client state.
    - Do not weaken any row-level security policy from schema.sql to make a query work. If a
      query is denied, the query is wrong.
    
    ## Growth Features (Required for Distribution)
    - **Product-led Growth:** the invite is the growth loop, so it must work before anything
      else is polished. Make the invite link shareable outside email, and show the inviter
      when an invite is accepted.

    schema.sql

    Download

    Tables, policies and functions

    -- SaaS starter schema for Lovable + Supabase
    -- Reviewed row-level security. Read the comments before running this.
    --
    -- The recursive-policy trap: a policy on workspace_members that itself queries
    -- workspace_members causes infinite recursion. The is_workspace_member() function below is
    -- SECURITY DEFINER specifically to break that cycle. Do not inline it into a policy.
    
    create type public.workspace_role as enum ('owner', 'admin', 'member');
    
    create table public.profiles (
      id uuid primary key references auth.users (id) on delete cascade,
      full_name text,
      avatar_url text,
      created_at timestamptz not null default now()
    );
    
    create table public.workspaces (
      id uuid primary key default gen_random_uuid(),
      name text not null check (length(trim(name)) > 0),
      plan text not null default 'free' check (plan in ('free', 'paid')),
      created_by uuid not null references auth.users (id) on delete restrict,
      created_at timestamptz not null default now()
    );
    
    create table public.workspace_members (
      workspace_id uuid not null references public.workspaces (id) on delete cascade,
      user_id uuid not null references auth.users (id) on delete cascade,
      role public.workspace_role not null default 'member',
      created_at timestamptz not null default now(),
      primary key (workspace_id, user_id)
    );
    
    create table public.workspace_invites (
      id uuid primary key default gen_random_uuid(),
      workspace_id uuid not null references public.workspaces (id) on delete cascade,
      email text not null,
      role public.workspace_role not null default 'member',
      token uuid not null default gen_random_uuid() unique,
      invited_by uuid not null references auth.users (id) on delete cascade,
      accepted_at timestamptz,
      expires_at timestamptz not null default (now() + interval '7 days'),
      created_at timestamptz not null default now()
    );
    
    create index workspace_members_user_idx on public.workspace_members (user_id);
    create index workspace_invites_workspace_idx on public.workspace_invites (workspace_id);
    
    -- Membership and role lookups. SECURITY DEFINER to avoid recursive RLS.
    -- STABLE, and search_path pinned so the function cannot be hijacked by a shadowing schema.
    create or replace function public.is_workspace_member(_workspace_id uuid)
    returns boolean
    language sql
    security definer
    set search_path = public
    stable
    as $$
      select exists (
        select 1 from public.workspace_members
        where workspace_id = _workspace_id and user_id = auth.uid()
      );
    $$;
    
    create or replace function public.workspace_role_of(_workspace_id uuid)
    returns public.workspace_role
    language sql
    security definer
    set search_path = public
    stable
    as $$
      select role from public.workspace_members
      where workspace_id = _workspace_id and user_id = auth.uid();
    $$;
    
    -- Deny by default: enable RLS everywhere, then grant explicitly.
    alter table public.profiles enable row level security;
    alter table public.workspaces enable row level security;
    alter table public.workspace_members enable row level security;
    alter table public.workspace_invites enable row level security;
    
    -- Profiles: yours only.
    create policy "profiles: read own" on public.profiles
      for select to authenticated using (id = auth.uid());
    create policy "profiles: insert own" on public.profiles
      for insert to authenticated with check (id = auth.uid());
    create policy "profiles: update own" on public.profiles
      for update to authenticated using (id = auth.uid()) with check (id = auth.uid());
    
    -- Workspaces: members read; only owners rename or change plan.
    create policy "workspaces: read as member" on public.workspaces
      for select to authenticated using (public.is_workspace_member(id));
    create policy "workspaces: create own" on public.workspaces
      for insert to authenticated with check (created_by = auth.uid());
    create policy "workspaces: owner updates" on public.workspaces
      for update to authenticated
      using (public.workspace_role_of(id) = 'owner')
      with check (public.workspace_role_of(id) = 'owner');
    
    -- Members: visible to fellow members; managed by owner and admin.
    create policy "members: read as member" on public.workspace_members
      for select to authenticated using (public.is_workspace_member(workspace_id));
    create policy "members: admin writes" on public.workspace_members
      for insert to authenticated
      with check (public.workspace_role_of(workspace_id) in ('owner', 'admin'));
    create policy "members: admin updates" on public.workspace_members
      for update to authenticated
      using (public.workspace_role_of(workspace_id) in ('owner', 'admin'));
    create policy "members: admin deletes" on public.workspace_members
      for delete to authenticated
      using (public.workspace_role_of(workspace_id) in ('owner', 'admin'));
    
    -- Invites: never readable by anon. Acceptance goes through a function, not a SELECT.
    create policy "invites: read as member" on public.workspace_invites
      for select to authenticated using (public.is_workspace_member(workspace_id));
    create policy "invites: admin creates" on public.workspace_invites
      for insert to authenticated
      with check (
        public.workspace_role_of(workspace_id) in ('owner', 'admin')
        and invited_by = auth.uid()
      );
    
    -- Accepting an invite needs to read a row the invitee cannot select, so it is a function.
    create or replace function public.accept_workspace_invite(_token uuid)
    returns uuid
    language plpgsql
    security definer
    set search_path = public
    as $$
    declare
      v_invite public.workspace_invites;
    begin
      if auth.uid() is null then
        raise exception 'not authenticated';
      end if;
    
      select * into v_invite from public.workspace_invites
      where token = _token and accepted_at is null and expires_at > now();
    
      if not found then
        raise exception 'invite is invalid, expired, or already used';
      end if;
    
      insert into public.workspace_members (workspace_id, user_id, role)
      values (v_invite.workspace_id, auth.uid(), v_invite.role)
      on conflict (workspace_id, user_id) do nothing;
    
      update public.workspace_invites set accepted_at = now() where id = v_invite.id;
    
      return v_invite.workspace_id;
    end;
    $$;
    
    revoke all on function public.accept_workspace_invite(uuid) from public, anon;
    grant execute on function public.accept_workspace_invite(uuid) to authenticated;
    
    -- Creator becomes owner. Without this the creator cannot read the workspace they made,
    -- because the read policy requires membership.
    create or replace function public.add_creator_as_owner()
    returns trigger
    language plpgsql
    security definer
    set search_path = public
    as $$
    begin
      insert into public.workspace_members (workspace_id, user_id, role)
      values (new.id, new.created_by, 'owner');
      return new;
    end;
    $$;
    
    create trigger workspaces_add_owner
      after insert on public.workspaces
      for each row execute function public.add_creator_as_owner();

    verify.sql

    Download

    Policy verification queries

    -- Verification queries. Run these as an ordinary signed-in user, not as service_role.
    -- Every one of them must behave as described or a policy is wrong.
    
    -- 1. Should return only workspaces you are a member of, never every workspace.
    select id, name, plan from public.workspaces;
    
    -- 2. Should return zero rows for a workspace you do not belong to.
    --    Substitute a workspace id you are NOT a member of.
    select * from public.workspace_members
    where workspace_id = '00000000-0000-0000-0000-000000000000';
    
    -- 3. Should FAIL for a member (non-admin). If it succeeds, the write policy is too loose.
    insert into public.workspace_members (workspace_id, user_id, role)
    values ('<a workspace where you are role=member>', auth.uid(), 'owner');
    
    -- 4. Should FAIL. Nobody may read another workspace's invites.
    select token from public.workspace_invites
    where workspace_id = '00000000-0000-0000-0000-000000000000';
    
    -- 5. Should FAIL with 'invite is invalid, expired, or already used'.
    select public.accept_workspace_invite('00000000-0000-0000-0000-000000000000');
    
    -- 6. Confirm RLS is actually on. Every row must show rowsecurity = true.
    select tablename, rowsecurity from pg_tables
    where schemaname = 'public'
      and tablename in ('profiles','workspaces','workspace_members','workspace_invites');

    Next

    If you want a prompt rather than a whole starting point, the prompt library has 101 of them across six categories. If you are watching what this costs to build, how Lovable credits work explains why a vague prompt against a large project is expensive twice over.