Skip to main content
RapidDev - Software Development Agency

How to Build a Team Workspace with Lovable

Build a Slack-style team workspace in Lovable with workspaces, channels, and documents. Supabase Realtime delivers messages instantly while Presence tracks who is online. Security-definer RLS functions enforce multi-tenant isolation so members only see their workspace data — all without a separate server.

What you'll build

  • Multi-tenant workspace schema with workspace_members junction table and role-based access (owner, admin, member)
  • Channel system with public and private channels, channel_members for private access control
  • Realtime message feed with Supabase postgres_changes subscription per channel
  • Presence-powered online member sidebar showing who is currently active in the workspace
  • Document section inside each workspace where members can create and view shared documents
  • Security-definer RLS helper function that gates all data access behind workspace membership checks
  • Workspace switcher dropdown in the sidebar for users who belong to multiple workspaces
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced16 min read3.5–5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a Slack-style team workspace in Lovable with workspaces, channels, and documents. Supabase Realtime delivers messages instantly while Presence tracks who is online. Security-definer RLS functions enforce multi-tenant isolation so members only see their workspace data — all without a separate server.

What you're building

A team workspace is fundamentally a multi-tenant application: multiple isolated workspaces share the same database but users in workspace A must never see data from workspace B. Supabase RLS is the enforcement layer, but naive RLS policies that do repeated subqueries (SELECT workspace_id FROM workspace_members WHERE user_id = auth.uid()) get slow as membership tables grow.

The solution is a SECURITY DEFINER function is_workspace_member(workspace_id uuid) that runs with elevated privileges, performs a single indexed lookup, and returns boolean. Your RLS policies call this function instead of inline subqueries. Because the function caches its result within a transaction, it fires once per query instead of once per row.

The Realtime layer has two parallel subscriptions per channel view: a postgres_changes subscription on messages filtered by channel_id for new message delivery, and a Presence subscription on the workspace channel that tracks which users are actively viewing the app. The Presence channel uses track/untrack to maintain an online members map that updates the sidebar in real time.

Documents are stored as rows in a documents table with a body column (text, for now). Members can create, view, and list documents. A future step could add block-level editing, but the schema supports it from day one.

Final result

A production-ready multi-tenant team workspace with channels, Realtime messaging, Presence-powered online status, and shared documents.

Tech stack

LovableFrontend + Edge Functions
SupabaseDatabase + Realtime + Auth + Presence
shadcn/uiCommand, Dialog, ScrollArea, Avatar, Badge
React ContextActive workspace and channel state

Prerequisites

  • Lovable Pro account with Edge Functions enabled
  • Supabase project with URL and service role key saved to Cloud tab → Secrets
  • Supabase Auth enabled with at least one user for testing
  • Basic understanding of Supabase RLS — you will be reviewing the generated policies
  • Familiarity with React Context for managing the active workspace state

Build steps

1

Create the multi-tenant workspace schema

Design all tables and RLS policies that enforce workspace isolation. The security-definer helper function is the architectural centrepiece — ask Lovable to generate it along with the tables and policies.

prompt.txt
1Create a multi-tenant team workspace schema in Supabase. Tables:
2
31. workspaces: id, name, slug (text unique), avatar_url, owner_id (references auth.users), created_at
4
52. workspace_members: id, workspace_id (references workspaces), user_id (references auth.users), role (text: owner|admin|member), joined_at, UNIQUE(workspace_id, user_id)
6
73. channels: id, workspace_id (references workspaces), name (text), description (text), is_private (bool default false), created_by (references auth.users), created_at
8
94. channel_members: id, channel_id (references channels), user_id (references auth.users), joined_at, UNIQUE(channel_id, user_id)
10
115. messages: id, channel_id (references channels), user_id (references auth.users), body (text), metadata (jsonb default '{}'), created_at, edited_at
12
136. documents: id, workspace_id (references workspaces), title (text), body (text), created_by (references auth.users), updated_at, created_at
14
15Create a SECURITY DEFINER function:
16CREATE OR REPLACE FUNCTION is_workspace_member(p_workspace_id uuid)
17RETURNS boolean LANGUAGE sql SECURITY DEFINER STABLE AS $$
18 SELECT EXISTS (
19 SELECT 1 FROM workspace_members
20 WHERE workspace_id = p_workspace_id AND user_id = auth.uid()
21 );
22$$;
23
24RLS policies using this function:
25- workspaces: SELECT where is_workspace_member(id)
26- workspace_members: SELECT where is_workspace_member(workspace_id)
27- channels: SELECT where is_workspace_member(workspace_id) AND (is_private = false OR EXISTS (SELECT 1 FROM channel_members WHERE channel_id = channels.id AND user_id = auth.uid()))
28- messages: SELECT, INSERT where is_workspace_member((SELECT workspace_id FROM channels WHERE id = channel_id))
29- documents: SELECT, INSERT, UPDATE where is_workspace_member(workspace_id)

Pro tip: Ask Lovable to create a seed SQL function seed_workspace(user_id, workspace_name) that inserts a workspace, adds the user as owner in workspace_members, and creates three default channels: #general, #random, #announcements. Call it after signup to give every new user a ready workspace.

Expected result: All six tables are created with indexes and RLS policies. The is_workspace_member function exists. TypeScript types are generated. The app preview loads without errors.

2

Build the workspace layout with sidebar and channel list

Build the main app layout: a fixed left sidebar with the workspace name, channel list, online members section, and a workspace switcher. Use React Context to track the active workspace and channel IDs across the entire app.

prompt.txt
1Build the team workspace layout at src/layouts/WorkspaceLayout.tsx.
2
3Requirements:
4- Create a WorkspaceContext (React Context) that holds: activeWorkspaceId, setActiveWorkspaceId, activeChannelId, setActiveChannelId, workspaces (array)
5- Fetch workspaces the user belongs to via workspace_members JOIN workspaces
6- Sidebar (w-64, fixed left):
7 - Top: workspace name + dropdown trigger (Command component) for switching workspaces
8 - Section 'Channels': list of channels for activeWorkspaceId with # prefix. Clicking sets activeChannelId.
9 - Section 'Online Now': Avatar list from Presence (build in next step, placeholder for now)
10 - Bottom: user avatar, display name, settings gear icon
11- Main content area (flex-1, overflow hidden): renders children based on activeChannelId
12- If user belongs to only one workspace, skip the switcher and show the name as a static heading
13- Add a '+' button next to 'Channels' heading that opens a Dialog for creating a new channel
14- New channel Dialog form: name (lowercase, no spaces validate with zod), description, is_private Checkbox

Pro tip: Store activeWorkspaceId in localStorage so the user returns to the same workspace on reload. Update localStorage whenever setActiveWorkspaceId is called in Context. On app load, read from localStorage first, then fall back to the first workspace in the list.

Expected result: The workspace layout renders with a sidebar showing channels. Clicking a channel updates the main content area. The workspace switcher Command opens and lists available workspaces.

3

Build the Realtime message feed with Presence

Build the channel view: a message list with Supabase Realtime for new messages, and a Presence subscription to track online members. Both subscriptions share the same Supabase channel using different event types.

src/components/ChannelView.tsx
1// src/components/ChannelView.tsx (excerpt showing Realtime + Presence)
2import { useEffect, useRef, useState } from 'react'
3import { supabase } from '@/lib/supabase'
4import { useAuth } from '@/hooks/useAuth'
5
6type Message = { id: string; user_id: string; body: string; created_at: string }
7type OnlineUser = { user_id: string; display_name: string }
8
9export function useChannelRealtime(channelId: string | null, workspaceId: string | null) {
10 const { user } = useAuth()
11 const [messages, setMessages] = useState<Message[]>([])
12 const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([])
13 const bottomRef = useRef<HTMLDivElement>(null)
14
15 useEffect(() => {
16 if (!channelId || !workspaceId || !user?.id) return
17
18 // Load recent messages
19 supabase
20 .from('messages')
21 .select('id, user_id, body, created_at')
22 .eq('channel_id', channelId)
23 .order('created_at', { ascending: true })
24 .limit(50)
25 .then(({ data }) => setMessages(data ?? []))
26
27 // Realtime messages subscription
28 const realtimeChannel = supabase.channel(`workspace:${workspaceId}:channel:${channelId}`)
29
30 realtimeChannel
31 .on(
32 'postgres_changes',
33 { event: 'INSERT', schema: 'public', table: 'messages', filter: `channel_id=eq.${channelId}` },
34 (payload) => {
35 setMessages((prev) => [...prev, payload.new as Message])
36 setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
37 }
38 )
39 .on('presence', { event: 'sync' }, () => {
40 const state = realtimeChannel.presenceState<OnlineUser>()
41 setOnlineUsers(Object.values(state).flat())
42 })
43 .subscribe(async (status) => {
44 if (status === 'SUBSCRIBED') {
45 await realtimeChannel.track({ user_id: user.id, display_name: user.email ?? 'Unknown' })
46 }
47 })
48
49 return () => { supabase.removeChannel(realtimeChannel) }
50 }, [channelId, workspaceId, user?.id])
51
52 return { messages, onlineUsers, bottomRef }
53}

Pro tip: Track the Presence payload with the workspace-scoped channel, not a separate channel. This means one subscribe() call maintains both new-message delivery and the online member list. Adding a second channel for Presence doubles your Supabase Realtime connection count unnecessarily.

Expected result: Opening a channel loads recent messages. New messages from any user appear instantly. The online members sidebar updates when users open or close the app.

4

Build the documents section

Add a documents area to the workspace. Members can create and view shared documents. The document list is in the sidebar under a 'Documents' section, and clicking opens a full-screen document view with a title and body editor.

prompt.txt
1Add a documents section to the workspace. Requirements:
2
3- Add 'Documents' section to the sidebar below Channels, showing document titles with a file icon
4- Clicking a document title sets activeDocumentId in WorkspaceContext and renders DocumentView in the main area
5- DocumentView at src/components/DocumentView.tsx:
6 - Show document title as a large heading (editable inline click to edit, Enter or blur to save)
7 - Show body as a Textarea with auto-resize (no character limit)
8 - Show last updated timestamp and created_by user display name
9 - Save on blur: supabase.from('documents').update({ body, updated_at: new Date() }).eq('id', documentId)
10 - Show a saving spinner during the update
11- Add a '+' button next to 'Documents' heading that opens a Dialog for creating a new document
12 - New document form: title field only. Body starts empty.
13 - On create, insert into documents and immediately open the new document
14- Documents list fetches from documents table where workspace_id = activeWorkspaceId, ordered by updated_at DESC

Pro tip: Add a debounced auto-save instead of save-on-blur for the document body. Use a useCallback with a 1500ms debounce that fires supabase.from('documents').update(). Show a 'Saving...' badge that transitions to 'Saved' when the update resolves. This matches Google Docs-style behavior users expect.

Expected result: The sidebar shows a Documents section. Clicking a document opens the editor. Editing the title or body saves to Supabase. New documents appear in the sidebar list immediately after creation.

5

Add workspace invitation and member management

Build the workflow for inviting new members to a workspace via email. Store pending invitations in a workspace_invitations table and send the invite email from an Edge Function. When the invited user clicks the link and signs up, they are added to workspace_members.

prompt.txt
1Add workspace member management. Requirements:
2
31. Create workspace_invitations table: id, workspace_id, email, token (uuid default gen_random_uuid()), role (text default 'member'), invited_by (references auth.users), status (text: pending|accepted|expired), created_at, expires_at (default now() + interval '7 days')
4
52. Add a 'Members' section to workspace settings (a Sheet component triggered from the sidebar gear icon)
6 - Show current workspace_members with Avatar, display name, role Badge, and 'Remove' Button (admin only)
7 - Show pending invitations with email, sent date, and 'Cancel' Button
8 - 'Invite Member' Button opens a Dialog with email Input and role Select (member/admin)
9 - On invite submit: insert into workspace_invitations, then invoke the 'send-workspace-invite' Edge Function
10
113. Create Edge Function supabase/functions/send-workspace-invite/index.ts that:
12 - Reads the invitation row by ID
13 - Sends an email via Resend with a link: https://yourapp.com/join?token={invitation.token}
14 - Returns 200 on success
15
164. Create a /join page that reads the token query param, looks up the invitation, and on user login/signup calls an Edge Function to add them to workspace_members and mark the invitation accepted

Pro tip: Set expires_at to 7 days on invitation creation. Add a Supabase scheduled function that runs daily and sets status='expired' for rows where expires_at < now() AND status='pending'. This keeps the invitations table clean and prevents old links from working.

Expected result: The Members Sheet shows current members and pending invitations. Inviting a user sends an email with a join link. Clicking the link and signing up adds the user to the workspace.

Complete code

src/components/ChannelView.tsx
1import { useEffect, useRef, useState, useCallback } from 'react'
2import { Send } from 'lucide-react'
3import { Button } from '@/components/ui/button'
4import { Input } from '@/components/ui/input'
5import { ScrollArea } from '@/components/ui/scroll-area'
6import { Avatar, AvatarFallback } from '@/components/ui/avatar'
7import { supabase } from '@/lib/supabase'
8import { useAuth } from '@/hooks/useAuth'
9import { formatDistanceToNow } from 'date-fns'
10
11type Message = { id: string; user_id: string; body: string; created_at: string }
12type Props = { channelId: string; workspaceId: string; channelName: string }
13
14export function ChannelView({ channelId, workspaceId, channelName }: Props) {
15 const { user } = useAuth()
16 const [messages, setMessages] = useState<Message[]>([])
17 const [input, setInput] = useState('')
18 const [sending, setSending] = useState(false)
19 const bottomRef = useRef<HTMLDivElement>(null)
20
21 useEffect(() => {
22 if (!channelId || !user?.id) return
23 supabase.from('messages').select('id,user_id,body,created_at')
24 .eq('channel_id', channelId).order('created_at', { ascending: true }).limit(50)
25 .then(({ data }) => { setMessages(data ?? []); setTimeout(() => bottomRef.current?.scrollIntoView(), 50) })
26
27 const channel = supabase.channel(`ws:${workspaceId}:ch:${channelId}`)
28 .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `channel_id=eq.${channelId}` },
29 payload => {
30 setMessages(prev => [...prev, payload.new as Message])
31 setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
32 })
33 .subscribe()
34 return () => { supabase.removeChannel(channel) }
35 }, [channelId, workspaceId, user?.id])
36
37 const sendMessage = useCallback(async () => {
38 if (!input.trim() || !user?.id || sending) return
39 setSending(true)
40 const body = input.trim()
41 setInput('')
42 await supabase.from('messages').insert({ channel_id: channelId, user_id: user.id, body })
43 setSending(false)
44 }, [input, channelId, user?.id, sending])
45
46 return (
47 <div className="flex flex-col h-full">
48 <div className="border-b px-4 py-2.5 flex items-center gap-2">
49 <span className="text-muted-foreground">#</span>
50 <span className="font-semibold">{channelName}</span>
51 </div>
52 <ScrollArea className="flex-1 px-4 py-2">
53 {messages.map(m => (
54 <div key={m.id} className="flex items-start gap-3 py-1.5">
55 <Avatar className="h-8 w-8 mt-0.5">
56 <AvatarFallback className="text-xs">{m.user_id.slice(0,2).toUpperCase()}</AvatarFallback>
57 </Avatar>
58 <div>
59 <div className="flex items-baseline gap-2">
60 <span className="text-sm font-medium">{m.user_id === user?.id ? 'You' : m.user_id.slice(0,8)}</span>
61 <span className="text-xs text-muted-foreground">{formatDistanceToNow(new Date(m.created_at), { addSuffix: true })}</span>
62 </div>
63 <p className="text-sm mt-0.5">{m.body}</p>
64 </div>
65 </div>
66 ))}
67 <div ref={bottomRef} />
68 </ScrollArea>
69 <div className="border-t p-3 flex gap-2">
70 <Input value={input} onChange={e => setInput(e.target.value)}
71 onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() } }}
72 placeholder={`Message #${channelName}`} className="flex-1" />
73 <Button size="icon" onClick={sendMessage} disabled={!input.trim() || sending}>
74 <Send className="h-4 w-4" />
75 </Button>
76 </div>
77 </div>
78 )
79}

Customization ideas

Thread replies on messages

Add a thread_parent_id column to messages that references another message row. In the channel view, messages with no thread_parent_id are top-level. Clicking the reply count opens a Sheet on the right showing the thread. Realtime subscriptions filter by thread_parent_id to keep thread and main feed updates separate.

Emoji reactions

Create a message_reactions table: id, message_id, user_id, emoji (text). Add a HoverCard on each message showing a '+' button that opens an emoji Popover. Clicking an emoji upserts a reaction row. Aggregate reactions by emoji with counts below each message using a GROUP BY query.

File uploads to messages

Add a file_url column to messages. Use Supabase Storage with a workspace-scoped bucket (path: workspace_id/channel_id/filename). Show a paperclip icon in the message input that triggers a file input element. Upload to Storage, get the public URL, then insert the message row with the file_url set.

Direct messages between workspace members

Add a dm_conversations table (id, workspace_id, participant_ids uuid[]) and extend messages with a dm_conversation_id column. In the sidebar, add a 'Direct Messages' section listing workspace members. Clicking a member opens or creates a DM conversation and shows the message feed with a DM-specific Realtime filter.

Channel pinned messages

Add a pinned_messages table (channel_id, message_id, pinned_by, pinned_at). Add a 'Pin message' option to a message context menu DropdownMenu. Show pinned messages at the top of the channel in a collapsible Alert component. Only admins can pin — check the user's workspace role before showing the pin option.

Common pitfalls

Pitfall: Putting workspace membership checks inline in every RLS policy

How to avoid: Use the SECURITY DEFINER is_workspace_member(workspace_id) function in all RLS policies. Postgres can cache the result within a query execution, and the function runs a single indexed lookup.

Pitfall: Creating separate Realtime channels for messages and Presence

How to avoid: Combine message postgres_changes and Presence on one channel: channel.on('postgres_changes', ...).on('presence', ...).subscribe(). Both event types work on the same channel object.

Pitfall: Forgetting to remove the user from Presence when the component unmounts

How to avoid: The cleanup in return () => { supabase.removeChannel(channel) } handles both Realtime unsubscribe and Presence untrack automatically when the channel is destroyed.

Pitfall: Fetching all channels for the workspace on every render

How to avoid: Fetch the channel list once in WorkspaceContext when activeWorkspaceId changes. Use a separate Realtime subscription on the channels table (INSERT/DELETE events) to update the list reactively without polling.

Best practices

  • Namespace every Realtime channel with both workspace ID and channel ID: `ws:${workspaceId}:ch:${channelId}`. This prevents cross-workspace message delivery if two workspaces have channels with the same UUID prefix.
  • Store activeWorkspaceId in localStorage so users return to the same workspace on reload without an extra redirect.
  • Limit initial message load to 50 rows with .limit(50). Implement scroll-up-to-load-more using Intersection Observer to fetch older messages on demand.
  • Use SECURITY DEFINER functions for all multi-tenant RLS checks to avoid per-row subquery performance penalties.
  • Track Presence with enough user context to render the online sidebar without a secondary fetch: { user_id, display_name, avatar_url }.
  • Add a channels.last_message_at column updated via a Postgres trigger on INSERT to messages. Sort the channel list by this column descending so active channels float to the top.
  • Validate channel names with a regex (^[a-z0-9-]+$) on the client with zod before inserting. Inconsistent channel name formats (spaces, capitals) make deep-linking and channel search unreliable.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a multi-tenant team workspace with Supabase RLS. I have a workspace_members table and I'm using a SECURITY DEFINER function is_workspace_member(workspace_id) in my RLS policies. Should I mark this function as STABLE or VOLATILE? What are the caching implications for Postgres query planning? Also, should I use SECURITY DEFINER for the channels and messages policies, or is there a better pattern for nested resource checks like 'user must be a member of the workspace that owns this channel'?

Lovable Prompt

Add a typing indicator to the channel view. When the user types in the message input, broadcast a 'typing' event on the Presence channel: { event: 'typing', user_id, display_name }. In the channel view footer, show a '{{name}} is typing...' line that appears when a typing event arrives and disappears after 3 seconds with no new events. Handle multiple users typing simultaneously by joining their names: 'Alice and Bob are typing...'.

Build Prompt

In Supabase, create a Postgres function get_workspace_channels(p_workspace_id uuid) that returns all channels the current user can see: public channels in the workspace plus private channels where the user is in channel_members. Return id, name, is_private, and last_message_at. Mark it SECURITY DEFINER so it respects the is_workspace_member check internally without exposing raw RLS policy logic to the caller.

Frequently asked questions

How do I prevent users from accessing another workspace's data even if they guess the UUID?

Supabase RLS enforces this at the database level. Your is_workspace_member(workspace_id) function checks if auth.uid() has a row in workspace_members for that specific workspace. Even if a user crafts a direct Supabase query with a known UUID, Postgres evaluates the RLS policy before returning rows. Without a matching workspace_members row, the query returns empty regardless of the UUID.

What is SECURITY DEFINER and why is it used here?

SECURITY DEFINER means the function runs with the permissions of the user who created it (typically the postgres superuser), not the calling user. This is necessary for the RLS helper function because it needs to query workspace_members without being blocked by RLS on that table itself. Without SECURITY DEFINER, calling is_workspace_member() from an RLS policy would trigger another RLS check on workspace_members, potentially causing infinite recursion.

How many workspaces can a single user belong to?

There is no hard limit in the schema. The workspace_members table is a junction table that can hold as many (user_id, workspace_id) pairs as needed. The workspace switcher in the sidebar fetches all workspaces the user belongs to via a JOIN. For performance, add an index on workspace_members(user_id) so this query is fast even when a user belongs to hundreds of workspaces.

How do I handle message history as the messages table grows large?

Add a created_at DESC index on messages(channel_id, created_at DESC). Load the most recent 50 messages on channel open. For older messages, use cursor-based pagination: when the user scrolls to the top, fetch the next 50 rows where created_at < oldest_loaded_message_created_at. This keeps each query fast regardless of total message count.

Can I add voice or video calls to the workspace?

Yes, but this requires a third-party WebRTC service. The most common approach with Lovable is to integrate Daily.co or Livekit. Add a 'Start call' button to channel headers that calls an Edge Function to create a Daily room, then opens the Daily Prebuilt in an iframe overlay. Supabase Realtime Broadcast can signal other workspace members that a call is active.

How does Presence work when users have the app open in multiple browser tabs?

Each tab creates a separate Presence subscription. Supabase Realtime tracks them independently by socket connection. If a user has two tabs open, their user_id appears twice in the Presence state. De-duplicate by user_id when rendering the online members list: const uniqueOnline = Array.from(new Map(onlineUsers.map(u => [u.user_id, u])).values())

How do I make channels searchable across the workspace?

Add a shadcn/ui Command dialog triggered by Cmd+K. Fetch all visible channels and documents for the workspace. Use the Command's built-in filtering to search by name. For message search, add a separate search input that calls a Supabase RPC function using PostgreSQL full-text search: to_tsvector('english', body) @@ plainto_tsquery('english', query).

Can RapidDev help add custom features to this workspace build?

Yes. RapidDev builds production Lovable apps including complex multi-tenant systems with custom RLS policies, advanced Realtime patterns, and third-party integrations. Reach out if your workspace needs features beyond what this guide covers.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.