Lightweight CRM Template for Lovable
Companies, contacts and an append-only note timeline for a founder doing their own sales, with shared team reads and owner-only writes.
Stack: React + Vite, TypeScript, Tailwind CSS, shadcn/ui, Supabase
What is in the download
- A build prompt optimised for speed of entry rather than field completeness
- A schema with companies, contacts, notes and a pipeline stage enum
- Shared team reads with owner-only writes, and a with-check clause that blocks reassignment
- A GIN full-text index so search works without a separate search service
- Six verification queries, including one proving user B cannot steal user A's record
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.
Decide shared or private before running schema.sql
This schema is a shared team CRM: every authenticated user reads every record. For per-user isolation, change the select policies to owner_id = auth.uid() first.
Run schema.sql in the Supabase SQL editor
Run verify.sql as two different users
Queries 3 and 4 only mean anything when run by someone who does not own the record.
Paste prompt.md into Lovable
Things that catch people out
- owner_id defaults to auth.uid() in the schema, so the client never sends it. That is what makes ownership server-derived rather than a value the browser can choose.
- The with check clause on the update policies is the part people omit. Without it an owner can update their own row and set owner_id to somebody else, or a user can claim a record by rewriting the owner. Verification query 4 tests exactly that.
- Shared reads are a deliberate default for a sales team. It is the one policy decision in these templates you should consciously confirm rather than accept.
prompt.md
DownloadThe build prompt
# Context
Build a lightweight CRM for a small team tracking companies, the people at them, and what was
said. The target user is a founder doing their own sales, so speed of entry matters more than
completeness of fields, and nothing should require leaving the keyboard.
## Core Features (Priority Order)
1. Contact list with fast search across name, company and email
2. Contact detail view with a chronological note timeline
3. Add a note in two keystrokes from the contact view, no modal
4. Companies as first-class records, with their contacts listed
5. Ownership: every record has an owner, and the team can see all records
6. A simple pipeline stage on each contact, changed inline from the list
## Visual Style
- Dense list, roomy detail. The list is for scanning, the detail is for reading
- shadcn/ui: Table for the list, Command for search, Badge for stage, Avatar for contacts,
Textarea for notes, Select for stage changes
- Stage colours consistent between list and detail
## Technical Requirements
- Breakpoints sm 640px, md 768px, lg 1024px; the list becomes cards below md
- Search filters client-side against loaded rows for instant feedback, then falls back to a
server query when the term is longer than the loaded page
- Optimistic update on a stage change, reverted on failure with a visible message
- Empty states for no contacts, no search results and no notes, each different
- Dark mode via Tailwind's dark: prefix
## Implementation Strategy
1. The schema from schema.sql and the contact list read
2. Contact detail and the note timeline
3. Note creation, with the optimistic path and the failure path
4. Companies, then the company-to-contact relationship
5. Pipeline stage last, because it changes the list and the detail together
## Safe-Guard Instructions
- Ownership is server-derived from auth.uid(). Never accept an owner id from the client.
- A note is append-only in the UI even though the schema allows the author to edit their own.
Do not add bulk delete.
- Do not add a policy that lets a user reassign another user's records.
## Growth Features (Required for Distribution)
- **B2B Sales:** make one contact view shareable as a read-only summary, so a colleague can
see the history without an account, and make it obvious when a share link is active.schema.sql
DownloadCompanies, contacts, notes and policies
-- Lightweight CRM schema for Lovable + Supabase
-- Reviewed row-level security. Read the comments before running this.
--
-- Model choice worth understanding: this is a SHARED team CRM. Every authenticated user can
-- read every record, because a sales team that cannot see each other's pipeline is not
-- useful. Writes are restricted to the owner. If you need per-user isolation instead, change
-- the select policies to owner_id = auth.uid() and re-run the verification queries.
create type public.pipeline_stage as enum
('new', 'contacted', 'qualified', 'proposal', 'won', 'lost');
create table public.companies (
id uuid primary key default gen_random_uuid(),
name text not null check (length(trim(name)) > 0),
domain text,
owner_id uuid not null default auth.uid() references auth.users (id) on delete restrict,
created_at timestamptz not null default now()
);
create table public.contacts (
id uuid primary key default gen_random_uuid(),
company_id uuid references public.companies (id) on delete set null,
full_name text not null check (length(trim(full_name)) > 0),
email text,
phone text,
stage public.pipeline_stage not null default 'new',
owner_id uuid not null default auth.uid() references auth.users (id) on delete restrict,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table public.notes (
id uuid primary key default gen_random_uuid(),
contact_id uuid not null references public.contacts (id) on delete cascade,
body text not null check (length(trim(body)) > 0),
author_id uuid not null default auth.uid() references auth.users (id) on delete restrict,
created_at timestamptz not null default now()
);
create index contacts_stage_idx on public.contacts (stage);
create index contacts_owner_idx on public.contacts (owner_id);
create index contacts_company_idx on public.contacts (company_id);
create index notes_contact_created_idx on public.notes (contact_id, created_at desc);
-- Fast search without a separate search service.
create index contacts_search_idx on public.contacts
using gin (to_tsvector('simple', coalesce(full_name, '') || ' ' || coalesce(email, '')));
alter table public.companies enable row level security;
alter table public.contacts enable row level security;
alter table public.notes enable row level security;
-- Shared reads for the team, owner-only writes.
create policy "companies: team reads" on public.companies
for select to authenticated using (true);
create policy "companies: insert as self" on public.companies
for insert to authenticated with check (owner_id = auth.uid());
create policy "companies: owner updates" on public.companies
for update to authenticated
using (owner_id = auth.uid()) with check (owner_id = auth.uid());
create policy "companies: owner deletes" on public.companies
for delete to authenticated using (owner_id = auth.uid());
create policy "contacts: team reads" on public.contacts
for select to authenticated using (true);
create policy "contacts: insert as self" on public.contacts
for insert to authenticated with check (owner_id = auth.uid());
-- The with check clause is what stops an owner handing a record to someone else.
create policy "contacts: owner updates" on public.contacts
for update to authenticated
using (owner_id = auth.uid()) with check (owner_id = auth.uid());
create policy "contacts: owner deletes" on public.contacts
for delete to authenticated using (owner_id = auth.uid());
create policy "notes: team reads" on public.notes
for select to authenticated using (true);
create policy "notes: insert as self" on public.notes
for insert to authenticated with check (author_id = auth.uid());
create policy "notes: author updates" on public.notes
for update to authenticated
using (author_id = auth.uid()) with check (author_id = auth.uid());
create policy "notes: author deletes" on public.notes
for delete to authenticated using (author_id = auth.uid());
create or replace function public.touch_updated_at()
returns trigger language plpgsql set search_path = public as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger contacts_touch_updated_at
before update on public.contacts
for each row execute function public.touch_updated_at();verify.sql
DownloadPolicy verification queries
-- Verification queries. Run these as user A, then sign in as user B and repeat 3 and 4.
-- 1. Should return the whole team's contacts. This schema is deliberately shared.
select id, full_name, stage, owner_id from public.contacts limit 20;
-- 2. Should SUCCEED, and owner_id should default to you without being supplied.
insert into public.contacts (full_name, email) values ('Verify Person', 'v@example.com');
select full_name, owner_id = auth.uid() as owned_by_me
from public.contacts where email = 'v@example.com';
-- As user B, against a contact owned by user A:
-- 3. Should FAIL or affect zero rows. You cannot edit someone else's record.
update public.contacts set stage = 'won' where owner_id <> auth.uid();
-- 4. Should FAIL or affect zero rows. You cannot reassign a record to yourself.
update public.contacts set owner_id = auth.uid() where owner_id <> auth.uid();
-- 5. Should FAIL. A note must be attributed to its actual author.
insert into public.notes (contact_id, body, author_id)
values ((select id from public.contacts limit 1), 'spoofed', '00000000-0000-0000-0000-000000000000');
-- 6. Confirm RLS is on for all three tables.
select tablename, rowsecurity from pg_tables
where schemaname = 'public' and tablename in ('companies', 'contacts', 'notes');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.