What you’re building
Pick the one Airtable base your team lives in, and only if its fields have stopped changing. This blueprint uses a lightweight CRM as the worked example. The same pattern fits an inventory list or a content calendar.
A version scoped to what most teams actually use in an Airtable CRM base:
- Companies, contacts, deals and activities, linked the way your base links them: a contact belongs to a company, a deal belongs to a company and has a main contact, and activities (notes, calls, emails, meetings) attach to a deal or a contact.
- The views your team uses: a deal pipeline board by stage, a filterable table for each record type and a “My deals” list.
- Forms to add and edit records, with the same required fields and dropdown options as your base.
- Roles: admins manage the team and can delete records, editors add and edit, and viewers read everything. As in Airtable, read-only seats cost nothing.
- CSV import from your Airtable base, so the switch takes an afternoon.
Not in version 1: changing fields on the fly, Interfaces, automations, extensions, record history and the long tail of integrations. Add them later only if you miss them.
Stack
- Supabase for Postgres, magic-link sign-in and row-level security. Your team depends on this from day one, so use Pro ($25/month) before anyone moves in. It never pauses and comes with daily backups.
- Give the CRM its own Supabase project. If it shares a project with a customer-facing app, those customers have accounts in the same place.
- Lovable or Claude Code to write the app (React + Tailwind is fine).
- Netlify or Vercel to host it on
crm.yourdomain.com.
Data model
Run this in the Supabase SQL editor. It creates the tables, gives every new sign-in a member row with no access, keeps updated_at honest, and locks everything down with row-level security (RLS) before any data goes in. Change the stage list to match your base.
-- The app talks to Supabase over REST, not GraphQL. Turning GraphQL off closes
-- an endpoint you don't use and clears two Security Advisor warnings.
drop extension if exists pg_graphql;
-- Everyone who signs in gets a row here, with no access until an admin sets a role.
create table public.members (
user_id uuid primary key references auth.users on delete cascade,
email text not null,
display_name text not null default '',
role text not null default 'pending' check (role in ('admin', 'editor', 'viewer', 'pending')),
created_at timestamptz not null default now()
);
create table public.companies (
id uuid primary key default gen_random_uuid(),
name text not null check (char_length(name) between 1 and 200),
website text,
industry text,
owner_id uuid references public.members on delete set null,
notes text check (char_length(notes) <= 10000),
airtable_id text unique,
created_by uuid default auth.uid() references auth.users on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index companies_name on public.companies (lower(name));
create table public.contacts (
id uuid primary key default gen_random_uuid(),
company_id uuid references public.companies on delete set null,
full_name text not null check (char_length(full_name) between 1 and 200),
email text,
phone text,
job_title text,
owner_id uuid references public.members on delete set null,
airtable_id text unique,
created_by uuid default auth.uid() references auth.users on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index contacts_company on public.contacts (company_id);
create table public.deals (
id uuid primary key default gen_random_uuid(),
name text not null check (char_length(name) between 1 and 200),
company_id uuid references public.companies on delete set null,
contact_id uuid references public.contacts on delete set null,
stage text not null default 'lead' check (stage in ('lead', 'qualified', 'proposal', 'won', 'lost')),
amount numeric(12, 2) check (amount >= 0),
close_date date,
owner_id uuid references public.members on delete set null,
airtable_id text unique,
created_by uuid default auth.uid() references auth.users on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index deals_stage on public.deals (stage, close_date);
create index deals_company on public.deals (company_id);
create index deals_contact on public.deals (contact_id);
create index deals_owner on public.deals (owner_id);
create table public.activities (
id uuid primary key default gen_random_uuid(),
deal_id uuid references public.deals on delete cascade,
contact_id uuid references public.contacts on delete cascade,
kind text not null default 'note' check (kind in ('note', 'call', 'email', 'meeting')),
body text not null check (char_length(body) between 1 and 10000),
happened_at timestamptz not null default now(),
airtable_id text unique,
created_by uuid default auth.uid() references auth.users on delete set null,
created_at timestamptz not null default now(),
check (deal_id is not null or contact_id is not null)
);
create index activities_deal on public.activities (deal_id, happened_at desc);
create index activities_contact on public.activities (contact_id, happened_at desc);
-- Privileged helpers live in a schema the API can't reach.
create schema if not exists private;
-- Your role, or null if you're not on the team. Every policy below uses it.
create function private.my_role() returns text
language sql stable security definer set search_path = public as $$
select role from public.members where user_id = (select auth.uid())
$$;
-- New sign-ins get a member row with no access.
create function private.handle_new_user() returns trigger
language plpgsql security definer set search_path = public as $$
begin
insert into public.members (user_id, email, display_name)
values (new.id, coalesce(new.email, ''), split_part(coalesce(new.email, ''), '@', 1))
on conflict (user_id) do nothing;
return new;
end $$;
create trigger on_auth_user_created_crm after insert on auth.users
for each row execute function private.handle_new_user();
create function private.touch_updated_at() returns trigger
language plpgsql security definer set search_path = public as $$
begin
new.updated_at := now();
return new;
end $$;
create trigger companies_touch before update on public.companies
for each row execute function private.touch_updated_at();
create trigger contacts_touch before update on public.contacts
for each row execute function private.touch_updated_at();
create trigger deals_touch before update on public.deals
for each row execute function private.touch_updated_at();
revoke execute on all functions in schema private from public;
grant execute on function private.my_role() to authenticated;
-- Anyone who signed in before you ran this
insert into public.members (user_id, email, display_name)
select id, coalesce(email, ''), split_part(coalesce(email, ''), '@', 1) from auth.users
on conflict (user_id) do nothing;
-- Row-level security
alter table public.members enable row level security;
alter table public.companies enable row level security;
alter table public.contacts enable row level security;
alter table public.deals enable row level security;
alter table public.activities enable row level security;
-- Team: members see the team, pending users see only their own row, admins manage roles.
create policy "Own row, or the whole team for members" on public.members for select to authenticated
using (user_id = (select auth.uid()) or (select private.my_role()) in ('admin', 'editor', 'viewer'));
create policy "Admins change names and roles" on public.members for update to authenticated
using ((select private.my_role()) = 'admin') with check ((select private.my_role()) = 'admin');
create policy "Admins remove access" on public.members for delete to authenticated
using ((select private.my_role()) = 'admin');
-- Companies, contacts and deals: the team reads, editors write, admins delete.
create policy "Team reads companies" on public.companies for select to authenticated
using ((select private.my_role()) in ('admin', 'editor', 'viewer'));
create policy "Editors add companies" on public.companies for insert to authenticated
with check ((select private.my_role()) in ('admin', 'editor') and created_by = (select auth.uid()));
create policy "Editors edit companies" on public.companies for update to authenticated
using ((select private.my_role()) in ('admin', 'editor')) with check ((select private.my_role()) in ('admin', 'editor'));
create policy "Admins delete companies" on public.companies for delete to authenticated
using ((select private.my_role()) = 'admin');
create policy "Team reads contacts" on public.contacts for select to authenticated
using ((select private.my_role()) in ('admin', 'editor', 'viewer'));
create policy "Editors add contacts" on public.contacts for insert to authenticated
with check ((select private.my_role()) in ('admin', 'editor') and created_by = (select auth.uid()));
create policy "Editors edit contacts" on public.contacts for update to authenticated
using ((select private.my_role()) in ('admin', 'editor')) with check ((select private.my_role()) in ('admin', 'editor'));
create policy "Admins delete contacts" on public.contacts for delete to authenticated
using ((select private.my_role()) = 'admin');
create policy "Team reads deals" on public.deals for select to authenticated
using ((select private.my_role()) in ('admin', 'editor', 'viewer'));
create policy "Editors add deals" on public.deals for insert to authenticated
with check ((select private.my_role()) in ('admin', 'editor') and created_by = (select auth.uid()));
create policy "Editors edit deals" on public.deals for update to authenticated
using ((select private.my_role()) in ('admin', 'editor')) with check ((select private.my_role()) in ('admin', 'editor'));
create policy "Admins delete deals" on public.deals for delete to authenticated
using ((select private.my_role()) = 'admin');
-- Activities: the team reads, editors log as themselves, authors fix their own, admins fix any.
create policy "Team reads activities" on public.activities for select to authenticated
using ((select private.my_role()) in ('admin', 'editor', 'viewer'));
create policy "Editors log activities as themselves" on public.activities for insert to authenticated
with check ((select private.my_role()) in ('admin', 'editor') and created_by = (select auth.uid()));
create policy "Authors edit their activities, admins any" on public.activities for update to authenticated
using ((select private.my_role()) = 'admin' or ((select private.my_role()) = 'editor' and created_by = (select auth.uid())))
with check ((select private.my_role()) = 'admin' or ((select private.my_role()) = 'editor' and created_by = (select auth.uid())));
create policy "Authors delete their activities, admins any" on public.activities for delete to authenticated
using ((select private.my_role()) = 'admin' or ((select private.my_role()) = 'editor' and created_by = (select auth.uid())));
-- Table and column access. New Supabase projects don't grant this automatically.
-- Nobody can change created_by, created_at or a member's email from the browser.
revoke all on public.members, public.companies, public.contacts, public.deals, public.activities from anon, authenticated;
grant select, delete on public.members, public.companies, public.contacts, public.deals, public.activities to authenticated;
grant insert on public.companies, public.contacts, public.deals, public.activities to authenticated;
grant update (display_name, role) on public.members to authenticated;
grant update (name, website, industry, owner_id, notes) on public.companies to authenticated;
grant update (company_id, full_name, email, phone, job_title, owner_id) on public.contacts to authenticated;
grant update (name, company_id, contact_id, stage, amount, close_date, owner_id) on public.deals to authenticated;
grant update (deal_id, contact_id, kind, body, happened_at) on public.activities to authenticated;
grant select, insert, update, delete on public.members, public.companies, public.contacts, public.deals, public.activities to service_role;
Then make yourself an admin after you first sign in: update public.members set role = 'admin' where email = 'you@yourdomain.com';
Invite teammates by email from Supabase Auth, then turn off Allow new users to sign up. Each new person starts as pending and sees nothing until an admin gives them a role on the Team page.
Build it in 9 prompts
Paste these one at a time into Lovable or Claude Code. Check each step works before moving on; it saves credits.
1. Create a React + Tailwind app connected to my Supabase project (project URL and publishable key from Settings → API Keys). Add magic-link sign-in with Supabase Auth. The database already exists: members, companies, contacts, deals and activities, with RLS. After sign-in, read the user's own row from members. If their role is pending, show "Ask an admin to give you access" and a sign-out button. Otherwise show a sidebar with Pipeline, Deals, Companies, Contacts, and Team and Import for admins. Hide create and edit buttons from viewers (the database blocks them anyway). Never use a secret or service role key in the browser.
2. Build Companies. A table with search by name, sort by any column, and filters for owner and industry. A create and edit form with name (required), website (must be a valid URL), industry as a dropdown, owner (a member picker) and notes. A company page that shows its details, its contacts, its deals and the activity timeline from its contacts and deals. Show who created the record and when it was last updated.
3. Build Contacts the same way: a searchable, filterable table; a form with full name (required), company (a searchable picker), email and phone (validated), job title and owner; and a contact page with their deals and an activity timeline. Warn before saving a contact whose email already exists.
4. Build Deals and the pipeline. /pipeline shows a board with one column per stage (lead, qualified, proposal, won, lost); each card shows the deal name, company, amount, close date and owner initials, and each column shows its count and total amount. Dragging a card to another column updates only that deal's stage, optimistically, and rolls back with a message if the update fails. Add a "My deals" toggle (owner is me), a table view with the same filters, and a deal form with name, company, main contact (limited to that company's contacts), stage, amount, close date and owner.
5. Add activities. On deal and contact pages, show a timeline of activities (newest first) with kind, body, happened_at and author. Editors and admins can log a note, call, email or meeting. Authors can edit or delete their own entries, and admins can edit or delete any (hide the buttons for everyone else). Render the body as plain text with line breaks, never as HTML.
6. Build /team for admins. List members with email, display name and role, pending ones first. Admins can change display_name and role (admin, editor, viewer, pending) and remove someone's access by deleting their members row. Don't let the last admin demote or remove themselves. Explain on the page that new people are invited from Supabase Auth and start as pending.
7. Build /import for admins, for Airtable CSV exports. Steps: pick the record type (companies, then contacts, then deals, then activities), upload a CSV, map its columns to our fields with a preview of the first 20 rows, then import. Store each row's Airtable record ID in airtable_id and skip rows whose airtable_id already exists, so re-running is safe. Resolve links (a contact's company, a deal's company and contact, an activity's deal or contact) by the linked record ID column if the CSV has one, otherwise by exact name, case-insensitive. Match owners to members by email. Map stage names to our stages. Do a dry run first that shows how many rows will be created, skipped and left unmatched, and offer a CSV of the unmatched rows after the import.
8. Make views shareable and exportable. Keep every table's search, filters and sort in the URL so a view can be bookmarked or sent to a teammate. Add an "Export CSV" button to each table that exports the current filtered rows, prefixing any cell that starts with =, +, - or @ with a single quote so spreadsheets don't run it as a formula.
9. Final pass: loading and empty states on every page, friendly error messages when a save fails (including "You don't have permission to do that"), dates shown in each person's time zone, amounts formatted as currency, page titles, keyboard focus styles, and a layout that works on a phone. List anything you couldn't finish.
Security checklist
- Supabase Security Advisor shows no warnings, and RLS is on for every table.
- Only the publishable (anon) key is in the browser. No secret or service role key is in the code or the repo.
- Allow new users to sign up is off. A new account starts as
pendingand sees no records until an admin sets a role (try it with a test account). - A viewer can’t create, edit or delete anything, even from the browser console with the Supabase client.
- An editor can’t delete companies, contacts or deals, can’t change roles, and can only edit or delete their own activities.
- Nobody can change
created_by,created_ator a member’s email from the browser. - The CRM has its own Supabase project, so customers of your other apps can’t sign in to it.
- Notes and activity text render as plain text, never as raw HTML, and CSV exports escape formula characters.
- Daily backups are on (Supabase Pro), and someone on the team knows how to restore one.
Move off Airtable
- Freeze the structure: agree that nobody adds or renames fields for two weeks. If that’s impossible, the verdict flips and Airtable is still the better tool.
- In each Airtable table, add a formula field with
RECORD_ID(). In tables with links (contacts, deals, activities), add a lookup field that shows each linked record’s ID. This keeps links intact when they pass through CSV. - Export each table as CSV from a grid view that shows every field. Airtable exports one view at a time, linked records come out as names (plus the ID lookups you just added), and attachment links expire within hours, so download any files you need first. You can also pull everything with Airtable’s API.
- Import with the app’s Import page in order: companies, contacts, deals, then activities. Compare record counts with Airtable and spot-check 20 records, including their links.
- Run both side by side for a week with your teammates set to read-only in Airtable. Re-import anything that changed (rows already imported are skipped), then switch everyone over.
- Cancel or downgrade Airtable after the last renewal you’ve paid for, and keep the CSV files as an archive.
After launch
- About 1 hour a month: update dependencies, check the Supabase advisor, review who has access, and make small field changes with a prompt.
- Watch for requests to add or rename fields every week, or a team that shrinks to one or two editors. Either one is the moment to reconsider, because Airtable’s flexibility or its small bill might be worth paying for again.
Before you build
Check the numbers for your own team first. Stack Scan reads a card statement in your browser and shows whether building beats what you actually pay.
Get new blueprints by email
One email when a new blueprint is out. Tell us which tool you want next.