TL;DR
The one-paragraph version before you dive in.
Paste the starter prompt into Lovable Build mode and get a multi-tenant Kanban project management app with organizations, projects, tasks, drag-and-drop ordering, Supabase Realtime live board updates, and an invite-by-email flow. Build time is roughly one day. Expected credit burn is 150–250 credits on a Pro $25/mo plan.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores organizations, members, projects, tasks, and comments — plus the two SECURITY DEFINER functions that power multi-tenant RLS.
- 1Click the + button next to Preview in the top toolbar to open the Cloud tab
- 2Click Database — you will see an empty Table Editor
- 3Leave it empty — the starter prompt will create all 5 tables, both SECURITY DEFINER functions, all RLS policies, and the Realtime publication in one migration
Auth
Email+password and Google OAuth for team members. The onboarding flow creates the first org and adds the creator as owner.
- 1Cloud tab → Users & Auth
- 2Enable Email (already on by default)
- 3Enable Google OAuth: toggle Google ON → copy the Client ID and Client Secret from your Google Cloud Console OAuth credentials → paste into the fields → Save
- 4Leave 'Allow new users to sign up' ON — new members will sign up via the invite link, and new founders will sign up on the /onboarding page
Edge Functions
invite-member sends Resend invite emails with signed tokens; notify-assignee sends task-assignment notifications.
- 1Cloud tab → Edge Functions
- 2No manual setup before the starter prompt — follow-up #2 will scaffold invite-member and follow-up #3 will scaffold notify-assignee
- 3After those follow-ups, add your secrets before deploying (see Secrets below)
Secrets (Cloud tab → Secrets)
RESEND_API_KEYPurpose: Enables invite-member Edge Function to send invitation emails and notify-assignee to send task assignment notifications
Where to get it: Sign up at resend.com → API Keys → Create API Key. Copy the key starting with re_.
INVITE_JWT_SECRETPurpose: Signs and verifies the invite tokens in invite links so only the intended email address can claim the pending members row
Where to get it: Generate a random 32-character string: use a password manager's generator or openssl rand -hex 32 from your local terminal (not inside Lovable — Lovable has no terminal).
Preflight checklist
- You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded — that's the default)
- You're on Pro $25/mo — multi-tenant RLS iteration typically runs 60–90 credits for the starter alone, and the full chain reaches 150–250 credits
- You understand this is a multi-tenant build from day one: every row in projects, tasks, and comments carries an org_id and is gated by org membership — you cannot bolt multi-tenancy on afterward
The starter prompt — paste this first
Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.
Build a multi-tenant Kanban project management tool. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router, Supabase JS client against Lovable Cloud, dnd-kit for drag-and-drop.
## Database schema (create one migration)
Create these 5 tables and enable RLS on ALL of them:
1. `organizations` — id (uuid pk default gen_random_uuid()), name (text not null), slug (text unique not null), created_by (uuid references auth.users(id)), created_at (timestamptz default now()).
2. `members` — id (uuid pk default gen_random_uuid()), org_id (uuid not null references organizations(id) on delete cascade), user_id (uuid references auth.users(id) on delete cascade), role (text not null default 'member', check role in ('owner','admin','member','viewer')), invited_email (text, null once accepted), accepted_at (timestamptz), created_at (timestamptz default now()). UNIQUE(org_id, user_id).
3. `projects` — id (uuid pk default gen_random_uuid()), org_id (uuid not null references organizations(id)), name (text not null), description (text), status (text default 'active', check in ('active','archived')), created_by (uuid references auth.users(id)), created_at (timestamptz default now()).
4. `tasks` — id (uuid pk default gen_random_uuid()), project_id (uuid not null references projects(id)), title (text not null), description (text), assignee_id (uuid references auth.users(id)), status (text not null default 'todo', check in ('todo','in_progress','done')), priority (text check in ('low','medium','high','urgent')), due_at (timestamptz), position (double precision not null default 0), created_at (timestamptz default now()).
5. `comments` — id (uuid pk default gen_random_uuid()), task_id (uuid references tasks(id) on delete cascade), author_id (uuid references auth.users(id)), body (text not null), created_at (timestamptz default now()).
ALSO add at the end of the migration:
`ALTER PUBLICATION supabase_realtime ADD TABLE tasks;`
Create these two SECURITY DEFINER plpgsql functions (NEVER SQL-language — they get inlined and lose the security boundary):
```sql
CREATE OR REPLACE FUNCTION is_org_member(p_org_id uuid)
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM members
WHERE org_id = p_org_id AND user_id = auth.uid()
);
END;
$$;
CREATE OR REPLACE FUNCTION can_see_project(p_project_id uuid)
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM projects p
JOIN members m ON m.org_id = p.org_id
WHERE p.id = p_project_id AND m.user_id = auth.uid()
);
END;
$$;
```
RLS policies (NEVER subquery members from a members policy — always go through the SECURITY DEFINER function):
- organizations SELECT: USING (is_org_member(id))
- members SELECT: USING (is_org_member(org_id))
- members INSERT: WITH CHECK (is_org_member(org_id)) — for accepting invites
- projects SELECT/INSERT/UPDATE: USING (is_org_member(org_id)) / WITH CHECK (is_org_member(org_id))
- tasks SELECT/INSERT/UPDATE/DELETE: USING (can_see_project(project_id)) / WITH CHECK (can_see_project(project_id))
- comments SELECT/INSERT: USING (can_see_project((SELECT project_id FROM tasks WHERE id = task_id)))
Also create a SECURITY DEFINER function `create_org(p_name text, p_slug text) RETURNS uuid` that inserts into organizations AND into members(owner) atomically — so /onboarding can call one RPC instead of two separate inserts that would race against the org policy.
## Layout
Create `src/layouts/AppLayout.tsx` — top bar with OrgSwitcher dropdown (reads members rows for current user, lists org names) + user menu; left sidebar with project list for current org + 'New project' button; main scrollable area.
Create `src/contexts/OrgContext.tsx` — stores current org_id + slug from the URL param `/org/:slug`, exposes useOrg() hook used by all queries.
Create `src/components/AuthGuard.tsx` — redirects to /login if no session.
Create `src/components/OrgGuard.tsx` — verifies is_org_member(org_id_from_slug), redirects to /onboarding if user is not a member of any org, or to the first available org.
## Pages and routes
Create these routes:
- `/login` — email+password + Google OAuth sign-in
- `/onboarding` — two cards: 'Create new organization' (calls create_org RPC) and 'Accept invite via token' (reads token from URL, verifies, updates members row)
- `/org/:slug` — org dashboard: project list grid + 'New project' button + recent activity
- `/org/:slug/project/:projectId` — Kanban board (todo / in_progress / done columns)
- `/org/:slug/project/:projectId/list` — list view with sort/filter
- `/org/:slug/team` — member list + 'Invite' button (opens InviteMemberDialog)
- `/org/:slug/settings` — org name, danger zone (delete org)
## Components
Create:
- `src/components/OrgSwitcher.tsx` — shadcn Select or DropdownMenu listing user's orgs
- `src/components/KanbanBoard.tsx` — dnd-kit DndContext with three DragOverlay columns (todo, in_progress, done). On drop: compute new position as midpoint between neighbors (`(prev.position + next.position) / 2`, or `prev.position + 1` at end). Call `supabase.from('tasks').update({status, position}).eq('id', taskId)` and await before clearing optimistic state.
- `src/components/TaskCard.tsx` — compact card (36px density): title + assignee avatar + due date chip + priority badge (gray low / blue medium / amber high / rose urgent)
- `src/components/TaskDetailDrawer.tsx` — shadcn Sheet (right, 560px). Full task form: title, description, assignee picker (list of org members), priority, due date, status. Comment list + new comment input below.
- `src/components/InviteMemberDialog.tsx` — email input + role selector. On submit, calls invite-member Edge Function (wired in follow-up #2).
- `src/hooks/useRealtimeTasks.ts` — subscribes to `postgres_changes` on tasks filtered by `project_id=eq.${projectId}`. On INSERT/UPDATE/DELETE, mutates TanStack Query cache.
## Styling
Light + dark mode via shadcn ThemeProvider. Primary color: violet-600. Status columns color-coded: gray (todo) / amber (in_progress) / emerald (done). Priority badges as above. 36px compact card density.
Generate the migration first (confirm it includes BOTH SECURITY DEFINER functions and the ALTER PUBLICATION line), then the context + guards, then the layout, then pages in order.What this prompt generates
- Creates a SQL migration with 5 tables, is_org_member() and can_see_project() SECURITY DEFINER functions, all multi-tenant RLS policies, Realtime publication on tasks, and create_org() RPC
- Scaffolds AppLayout with OrgSwitcher + sidebar, OrgContext, AuthGuard, and OrgGuard
- Generates 7 page components (Login, Onboarding, OrgDashboard, ProjectBoard, ProjectList, Team, OrgSettings) wired to React Router
- Builds KanbanBoard with dnd-kit drag-and-drop that persists position to the database, TaskCard, and TaskDetailDrawer with comments
- Adds useRealtimeTasks hook for live board updates via Supabase Realtime postgres_changes
Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
supabase/migrations/0001_project_mgmt_schema.sql5 tables + is_org_member + can_see_project SECURITY DEFINER functions + RLS policies + Realtime publication + create_org RPC
src/contexts/OrgContext.tsxCurrent org_id/slug from URL, exposes useOrg() hook
src/layouts/AppLayout.tsxTop bar with OrgSwitcher + sidebar with project list
src/components/AuthGuard.tsxRedirects to /login if no session
src/components/OrgGuard.tsxVerifies org membership, redirects to /onboarding or first org
src/components/OrgSwitcher.tsxDropdown listing user's orgs sourced from members rows
src/components/KanbanBoard.tsxdnd-kit board with 3 columns and position-persisting drop handler
src/components/TaskCard.tsxCompact task card with title, assignee avatar, due date, priority badge
src/components/TaskDetailDrawer.tsxRight Sheet with full task form + comment thread
src/components/InviteMemberDialog.tsxEmail + role picker that calls invite-member Edge Function
src/hooks/useRealtimeTasks.tsRealtime postgres_changes subscription that mutates TanStack Query cache
src/pages/Login.tsxEmail+password + Google OAuth sign-in
src/pages/Onboarding.tsxCreate org (via create_org RPC) or accept invite token
src/pages/OrgDashboard.tsxProject list + recent activity for current org
src/pages/ProjectBoard.tsxKanban board for a specific project
src/pages/Team.tsxMember list + invite button
src/pages/OrgSettings.tsxOrg name + danger zone
Routes
Email+password and Google OAuth sign-in
Create first org or accept invite token
Org dashboard with project list
Kanban board with live updates
List view with filters
Member directory and invite flow
Org settings and danger zone
DB Tables
organizations| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| name | text not null |
| slug | text unique not null |
| created_by | uuid references auth.users(id) |
| created_at | timestamptz default now() |
RLS: SELECT via is_org_member(id); UPDATE by owner/admin role only
members| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| org_id | uuid not null references organizations(id) on delete cascade |
| user_id | uuid references auth.users(id) on delete cascade |
| role | text not null default 'member' |
| invited_email | text |
| accepted_at | timestamptz |
RLS: SELECT via is_org_member(org_id) — NEVER subquery members from this policy or it recurses. INSERT WITH CHECK (is_org_member(org_id)) for invite acceptance.
projects| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| org_id | uuid not null references organizations(id) |
| name | text not null |
| status | text default 'active' |
RLS: SELECT/INSERT/UPDATE via is_org_member(org_id)
tasks| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| project_id | uuid not null references projects(id) |
| title | text not null |
| status | text not null default 'todo' |
| position | double precision not null default 0 |
| assignee_id | uuid references auth.users(id) |
| due_at | timestamptz |
| priority | text |
RLS: All operations via can_see_project(project_id). Table is in supabase_realtime publication for live board updates.
comments| Column | Type |
|---|---|
| id | uuid primary key default gen_random_uuid() |
| task_id | uuid references tasks(id) on delete cascade |
| author_id | uuid references auth.users(id) |
| body | text not null |
| created_at | timestamptz default now() |
RLS: SELECT/INSERT via can_see_project((SELECT project_id FROM tasks WHERE id = task_id))
Components
OrgSwitcherDropdown of user's orgs, navigates on select
OrgGuardVerifies org membership via is_org_member(), redirects appropriately
KanbanBoarddnd-kit board with position-persisting drag-and-drop
TaskCardCompact 36px task card with priority badge and due date
TaskDetailDrawerRight Sheet with full task form and comment thread
InviteMemberDialogEmail + role picker for the invite-member Edge Function
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Audit multi-tenant RLS (non-negotiable — do this first)
Verified multi-tenant data isolation — User A in Org 1 sees zero rows from Org 2
Audit every RLS policy in the migration. For each policy, confirm: 1. It calls is_org_member() or can_see_project() — NOT a raw subquery of the members table. 2. The members SELECT policy ONLY uses is_org_member(org_id), never `user_id = auth.uid()` or a subquery. 3. The tasks policies use can_see_project(project_id), not inline subqueries. Then run a test: - Create two test accounts in two different orgs (use Cloud tab → Users & Auth → Add user for each). - Sign in as User A in Org 1. Confirm they see ALL members of Org 1 and ZERO from Org 2. - Sign in as User B in Org 2. Confirm the same isolation. If any policy subqueries the members table directly, drop and recreate it using the SECURITY DEFINER function pattern. Output the fixed policies as a new SQL migration.
When to use: Immediately after the starter prompt finishes, before adding any real data
Add invite-by-email flow
Resend invite email with signed JWT token and secure onboarding acceptance flow
Create a Supabase Edge Function at `supabase/functions/invite-member/index.ts` (Deno). It should:
1. Read the Authorization header and verify the caller is admin or owner of the target org.
2. Accept POST body: `{org_id, email, role}`.
3. Create a members row with `org_id`, `invited_email=email`, `user_id=null`, `role`.
4. Sign a JWT (using INVITE_JWT_SECRET from Deno.env.get) with payload `{org_id, invited_email: email, member_id: row.id, exp: now+72h}`.
5. Send a Resend email (using RESEND_API_KEY from Deno.env.get) to the invited email with a link to `/onboarding?token=<signed_jwt>`.
Update /onboarding 'Accept invite' card:
1. Read `token` query param. Decode the JWT (verify signature against INVITE_JWT_SECRET).
2. If no auth session, store token in localStorage and redirect to /login?next=/onboarding.
3. After login, on /onboarding mount, read token from localStorage, verify `claims.invited_email === user.email` (case-insensitive).
4. Update the matching members row: `SET user_id = auth.uid(), invited_email = null, accepted_at = now() WHERE id = claims.member_id AND invited_email = claims.invited_email AND user_id IS NULL`.When to use: When you need to invite teammates without manually editing the database
Add task assignment notifications
Resend email to new task assignee whenever assignee_id changes, with opt-out toggle
Create a Supabase Edge Function at `supabase/functions/notify-assignee/index.ts` (Deno). It should:
1. Be triggered by a Postgres trigger: `CREATE TRIGGER notify_on_assignment AFTER UPDATE OF assignee_id ON tasks FOR EACH ROW WHEN (NEW.assignee_id IS DISTINCT FROM OLD.assignee_id) EXECUTE FUNCTION supabase_functions.http_request('POST', '<YOUR_FUNCTION_URL>/notify-assignee', ...)`.
2. Receive the task row in the payload.
3. Fetch the new assignee's email from auth.users (using service role key from Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')).
4. Send a Resend email to the assignee with task title, project name, due date, and a link to the task.
Add a `notify_email` boolean (default true) to the members table. Check it before sending: only send if the assignee's members row has notify_email = true. Add a 'Email notifications' toggle in /org/:slug/settings per member.When to use: Once 3 or more people share a board and need to know when tasks land on them
Wire live board updates via Realtime
Live Kanban board — task cards animate to new columns when any team member moves them
Confirm the useRealtimeTasks hook subscribes correctly:
1. Open the hook. Ensure it calls `supabase.channel('tasks:' + projectId).on('postgres_changes', {event: '*', schema: 'public', table: 'tasks', filter: 'project_id=eq.' + projectId}, handler).subscribe()`.
2. In the handler, mutate the TanStack Query cache: on INSERT add the new task card to the correct column; on UPDATE move it to the new status column or update fields in place; on DELETE remove it.
3. Show a small shadcn Toast ('Alice moved Card X to In Progress') when another user changes a task.
4. Return the unsubscribe callback from the useEffect cleanup so the channel closes when the board unmounts.
If the table isn't in the publication yet, add: `ALTER PUBLICATION supabase_realtime ADD TABLE tasks;` in a new SQL migration and run it in Cloud tab → SQL Editor.When to use: When two or more people need to work on the same board simultaneously
Add file attachments to tasks
File upload area in task drawer — images and PDFs attached per task, scoped to org membership
Add file attachment support to TaskDetailDrawer:
1. Create a Storage bucket `task-attachments` (private). Add an RLS policy on storage.objects: `USING (bucket_id = 'task-attachments' AND EXISTS (SELECT 1 FROM projects p JOIN members m ON m.org_id = p.org_id WHERE (storage.foldername(name))[1] = p.id::text AND m.user_id = auth.uid()))`.
2. Add a `task_attachments` table: id (uuid pk), task_id (uuid references tasks(id) on delete cascade), storage_path (text not null), filename (text not null), size_bytes (int), uploaded_by (uuid references auth.users(id)), created_at (timestamptz). RLS: SELECT/INSERT/DELETE via can_see_project((SELECT project_id FROM tasks WHERE id = task_id)).
3. In TaskDetailDrawer, add a file picker (accepts image/* and application/pdf, max 10MB). On select, upload to `task-attachments/{project_id}/{task_id}/{uuid}-{filename}`, then insert a task_attachments row.
4. Render existing attachments as a row of thumbnails (images) or filename chips (PDFs) with a delete button and a download button (signed URL, 1 hour expiry).When to use: When text-only tasks feel limiting and your team attaches screenshots or specs to tasks
Add AI project summary widget
Claude Haiku 4.5-generated 3-bullet project status summary at the top of each Kanban board
Create a Supabase Edge Function at `supabase/functions/summarize-project/index.ts` (Deno). It should:
1. Accept POST with `{project_id}` from an authenticated user who passes the Authorization header.
2. Verify is_org_member for the project's org using the service role client.
3. Fetch the last 20 tasks (status, title, assignee_id, updated_at) and last 5 status-change comments.
4. Call Claude Haiku 4.5 via the Anthropic API (ANTHROPIC_API_KEY from Deno.env.get) with a prompt to generate a 3-bullet plain-English project summary: completed this week, in progress, any blockers.
5. Return the 3-bullet string.
At the top of ProjectBoard.tsx, add a shadcn Card with title 'Project snapshot'. Add a 'Generate summary' Button. On click, POST to summarize-project and render the 3 bullets. Show a Skeleton while loading.When to use: When you want to share a quick project status without reading 40 individual cards
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
infinite recursion detected in policy for relation "members"Your members RLS SELECT policy subqueries the members table itself (e.g., USING (org_id IN (SELECT org_id FROM members WHERE user_id = auth.uid()))). Every check triggers the policy again, causing infinite recursion. This is the most common Lovable multi-tenant build failure.
Create a SECURITY DEFINER plpgsql function: `CREATE OR REPLACE FUNCTION is_org_member(p_org_id uuid) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE AS $$ BEGIN RETURN EXISTS (SELECT 1 FROM members WHERE org_id = p_org_id AND user_id = auth.uid()); END; $$;`. Drop the current members SELECT policy. Recreate it: `CREATE POLICY members_org_visible ON members FOR SELECT TO authenticated USING (is_org_member(org_id));`. DO NOT use a SQL-language function — they get inlined by the optimizer and still recurse.
User can see members from another org they are not in (cross-tenant data leak)The members SELECT policy was scaffolded as USING (true) or USING (user_id = auth.uid()). The first leaks all rows; the second prevents users from seeing their own co-members.
Drop the members SELECT policy. Recreate it: `CREATE POLICY members_org_visible ON members FOR SELECT TO authenticated USING (is_org_member(org_id));`. Then test with two accounts in two orgs in an incognito window — User A in Org 1 must see ALL members of Org 1 and NONE from Org 2.
Drag-and-drop reorders cards visually but the old order comes back on page reloadThe KanbanBoard drop handler updates the TanStack Query cache optimistically but never calls supabase.from('tasks').update({status, position}) — the database still has the old values.
In KanbanBoard's onDragEnd handler, after computing the new position via midpoint rebalance (`(prev.position + next.position) / 2`, or `prev.position + 1` if dropped at the end), call `await supabase.from('tasks').update({status: newStatus, position: newPosition}).eq('id', activeTask.id)`. Await the call before clearing optimistic state. When two adjacent positions get closer than 0.0001, trigger a full rebalance: renumber all tasks in that column to 0, 100, 200, 300 and update all in a batch.Tasks don't update in real-time even though INSERT and UPDATE succeed in the databaseThe Realtime publication line was missing from the migration. Supabase Realtime only broadcasts changes for tables explicitly added to the supabase_realtime publication.
Manual fix
Run in Cloud tab → SQL Editor: `ALTER PUBLICATION supabase_realtime ADD TABLE tasks;`. Reload the browser tab and re-open the board page. Future migrations should include this line at the bottom.
new row violates row-level security policy for table "projects" when creating the first projectThe projects INSERT policy uses WITH CHECK (is_org_member(org_id)), but you just created the org and haven't inserted yourself as a member yet. The two-step insert races against the policy.
Wrap org creation and first-member insertion in the create_org SECURITY DEFINER function: `CREATE FUNCTION create_org(p_name text, p_slug text) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE new_org_id uuid; BEGIN INSERT INTO organizations(name, slug, created_by) VALUES (p_name, p_slug, auth.uid()) RETURNING id INTO new_org_id; INSERT INTO members(org_id, user_id, role) VALUES (new_org_id, auth.uid(), 'owner'); RETURN new_org_id; END; $$;`. The /onboarding page calls `supabase.rpc('create_org', {p_name, p_slug})` instead of two separate inserts.Invite email link redirects to /login in a loop and never accepts the inviteThe /onboarding page tries to read the invite token before an auth session exists — the user clicked the link in an incognito window without being signed in, so there's no session to verify against.
On /onboarding, check supabase.auth.getSession() first. If no session, store the full token in localStorage under key 'pendingInviteToken' and redirect to /login?next=/onboarding. On /onboarding mount after login, read the token from localStorage, decode it, verify `claims.invited_email === user.email` (case-insensitive), and update the matching members row with `user_id = auth.uid(), accepted_at = now()` WHERE `id = claims.member_id AND invited_email = claims.invited_email AND user_id IS NULL`.
Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo recommended — multi-tenant RLS iteration cycles are where credits go; budget one $15 top-up if you debug the recursion error more than twice
Monthly run cost breakdown
~150–250 credits (starter ~60–90, follow-ups 1–4 ~220 combined; follow-ups 5–6 add ~150 more if you do them all; most teams ship after follow-up #4 with Realtime working) total| Item | Cost |
|---|---|
| Lovable Pro Can drop to Free after launch — the app continues to run on Cloud | $25/mo while iterating |
| Supabase / Lovable Cloud Free tier: 500MB DB handles hundreds of orgs and thousands of tasks; 200 concurrent Realtime connections handles small teams | $0/mo at MVP scale |
| Resend Invite + assignment notification emails — 3K/mo is generous for a small-team PM tool | $0/mo up to 3K emails |
| Custom domain Point app.yourproduct.com at the published Lovable app | ~$10–15/yr |
Scaling notes: Realtime concurrent-connection cap (200 on Free, 500 on Supabase Pro) is the most likely first ceiling. At ~100 daily active board users with tabs open, you'll hit it. Supabase Pro $25/mo raises the cap to 500. Past that, swap Realtime for Pusher ($49–$499/mo depending on connections) or use a rate-limited 10-second polling fallback. The 500MB DB Free cap allows roughly 500K tasks before needing the 8GB Supabase Pro tier.
Production checklist
Steps to take before you share the URL with real users.
Security
Run multi-tenant isolation test before any real data
Create two test accounts in two separate orgs via Cloud tab → Users & Auth. Sign in as each in separate incognito windows. Confirm User A in Org 1 sees zero tasks, projects, and members from Org 2. If any cross-org row is visible, check the members SELECT policy first — it must use is_org_member(org_id), not a raw subquery.
Verify invite token validates email before claiming
Test the /onboarding token flow: create an invite for test@example.com, then try to claim it while logged in as a different email. The onboarding handler must reject with 'This invite was not sent to your email address.'
Domain & SSL
Set a custom domain for the published app
Publish → top-right toolbar → Settings → Custom domain. Add a CNAME record at your DNS provider. SSL is auto-provisioned. Update Google OAuth redirect URIs to include the new domain.
Realtime
Confirm tasks table is in supabase_realtime publication
Cloud tab → SQL Editor → run: `SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';`. Confirm tasks appears in the list. If not, run `ALTER PUBLICATION supabase_realtime ADD TABLE tasks;`.
Backups
Confirm daily backups are running
Cloud tab → Database → Backups. Check the last backup timestamp. Lovable Cloud retains daily backups for ~14 days. Export a manual backup before any large data migration.
Frequently asked questions
Why does Lovable struggle so much with multi-tenant RLS?
Lovable scaffolds RLS policies by pattern-matching your schema. When it sees a members table with an org_id column, it often writes the SELECT policy as USING (org_id IN (SELECT org_id FROM members WHERE user_id = auth.uid())), which subqueries the same table the policy is protecting. PostgreSQL detects the recursion and throws an error. The fix is a SECURITY DEFINER plpgsql function that the optimizer cannot inline — it runs with elevated privileges and bypasses the policy check for its own query. This pattern is what the starter prompt provides for both is_org_member() and can_see_project().
Can I start single-tenant and add orgs later?
You can, but it requires a significant schema migration: every tasks, projects, and comments row needs an org_id column added, all RLS policies need to be replaced, and all queries need an org_id filter added. If you have more than one user or expect to add a second team in the next 6 months, build multi-tenant from day one using this kit. The starter prompt includes create_org() so even a single-founder build starts with the right structure.
How do I prevent User A from seeing members from User B's org?
The members SELECT policy must use USING (is_org_member(org_id)) — the SECURITY DEFINER plpgsql function, not a raw subquery. If you see members from other orgs, your current policy is either USING (true) (leaks everything) or USING (user_id = auth.uid()) (shows only your own row, not your co-members). Drop and recreate the policy using the is_org_member function from the starter prompt.
What happens when two people move the same card at the same time?
dnd-kit's optimistic UI shows both moves locally. The Supabase updates race server-side — whichever arrives last wins. The final position in the database is correct (no data corruption), but one user's move might be visually reversed within a second or two as the Realtime subscription broadcasts the winning update. For a small team Kanban board this is acceptable. If you need stronger consistency, implement server-side optimistic locking with a version column and reject stale updates from the client.
How many concurrent users can the Realtime board handle on Free?
Lovable Cloud (Supabase Free tier) supports 200 concurrent Realtime connections. Each browser tab with an open board holds one connection. For a team of 20 with multiple tabs, you could hit this limit. Supabase Pro at $25/mo raises the cap to 500 concurrent connections. Beyond that, you'd need to either rate-limit subscriptions (one per org instead of one per board tab) or move to Pusher.
Should I use position as an integer or float for drag-reorder?
Use double precision (float) for fractional rebalancing: when you drop a card between two cards at positions 100 and 200, the new position is 150 — no need to renumber other rows. The trade-off is floating-point precision: after many drops between the same two cards, adjacent positions converge and eventually the difference is below 0.0001. Detect this and trigger a full-column renumber (0, 100, 200, 300...) before it becomes a problem. The starter prompt's KanbanBoard includes this check in the onDragEnd handler.
When is it cheaper to just pay for Asana or Linear?
If your team is under 10 people and you fit standard workflows, Asana Free or Trello Free are excellent and cost nothing — you'll spend more in Lovable credits than the SaaS would cost in several years. Build in Lovable when you need a vertical PM tool (construction, healthcare, legal) with custom fields, or you're building a SaaS product where end-customers each get their own org and per-seat fees would be prohibitive. If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.
Need a production-grade version?
RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.
Book a free consultation30-min call. No commitment.