# How to Build a Team Workspace with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
Create a multi-tenant team workspace schema in Supabase. Tables:

1. workspaces: id, name, slug (text unique), avatar_url, owner_id (references auth.users), created_at

2. workspace_members: id, workspace_id (references workspaces), user_id (references auth.users), role (text: owner|admin|member), joined_at, UNIQUE(workspace_id, user_id)

3. channels: id, workspace_id (references workspaces), name (text), description (text), is_private (bool default false), created_by (references auth.users), created_at

4. channel_members: id, channel_id (references channels), user_id (references auth.users), joined_at, UNIQUE(channel_id, user_id)

5. messages: id, channel_id (references channels), user_id (references auth.users), body (text), metadata (jsonb default '{}'), created_at, edited_at

6. documents: id, workspace_id (references workspaces), title (text), body (text), created_by (references auth.users), updated_at, created_at

Create a SECURITY DEFINER function:
CREATE OR REPLACE FUNCTION is_workspace_member(p_workspace_id uuid)
RETURNS boolean LANGUAGE sql SECURITY DEFINER STABLE AS $$
  SELECT EXISTS (
    SELECT 1 FROM workspace_members
    WHERE workspace_id = p_workspace_id AND user_id = auth.uid()
  );
$$;

RLS policies using this function:
- workspaces: SELECT where is_workspace_member(id)
- workspace_members: SELECT where is_workspace_member(workspace_id)
- 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()))
- messages: SELECT, INSERT where is_workspace_member((SELECT workspace_id FROM channels WHERE id = channel_id))
- 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.

```
Build the team workspace layout at src/layouts/WorkspaceLayout.tsx.

Requirements:
- Create a WorkspaceContext (React Context) that holds: activeWorkspaceId, setActiveWorkspaceId, activeChannelId, setActiveChannelId, workspaces (array)
- Fetch workspaces the user belongs to via workspace_members JOIN workspaces
- Sidebar (w-64, fixed left):
  - Top: workspace name + dropdown trigger (Command component) for switching workspaces
  - Section 'Channels': list of channels for activeWorkspaceId with # prefix. Clicking sets activeChannelId.
  - Section 'Online Now': Avatar list from Presence (build in next step, placeholder for now)
  - Bottom: user avatar, display name, settings gear icon
- Main content area (flex-1, overflow hidden): renders children based on activeChannelId
- If user belongs to only one workspace, skip the switcher and show the name as a static heading
- Add a '+' button next to 'Channels' heading that opens a Dialog for creating a new channel
- 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 (excerpt showing Realtime + Presence)
import { useEffect, useRef, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

type Message = { id: string; user_id: string; body: string; created_at: string }
type OnlineUser = { user_id: string; display_name: string }

export function useChannelRealtime(channelId: string | null, workspaceId: string | null) {
  const { user } = useAuth()
  const [messages, setMessages] = useState<Message[]>([])
  const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([])
  const bottomRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!channelId || !workspaceId || !user?.id) return

    // Load recent messages
    supabase
      .from('messages')
      .select('id, user_id, body, created_at')
      .eq('channel_id', channelId)
      .order('created_at', { ascending: true })
      .limit(50)
      .then(({ data }) => setMessages(data ?? []))

    // Realtime messages subscription
    const realtimeChannel = supabase.channel(`workspace:${workspaceId}:channel:${channelId}`)

    realtimeChannel
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'messages', filter: `channel_id=eq.${channelId}` },
        (payload) => {
          setMessages((prev) => [...prev, payload.new as Message])
          setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
        }
      )
      .on('presence', { event: 'sync' }, () => {
        const state = realtimeChannel.presenceState<OnlineUser>()
        setOnlineUsers(Object.values(state).flat())
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await realtimeChannel.track({ user_id: user.id, display_name: user.email ?? 'Unknown' })
        }
      })

    return () => { supabase.removeChannel(realtimeChannel) }
  }, [channelId, workspaceId, user?.id])

  return { messages, onlineUsers, bottomRef }
}
```

> 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.

```
Add a documents section to the workspace. Requirements:

- Add 'Documents' section to the sidebar below Channels, showing document titles with a file icon
- Clicking a document title sets activeDocumentId in WorkspaceContext and renders DocumentView in the main area
- DocumentView at src/components/DocumentView.tsx:
  - Show document title as a large heading (editable inline — click to edit, Enter or blur to save)
  - Show body as a Textarea with auto-resize (no character limit)
  - Show last updated timestamp and created_by user display name
  - Save on blur: supabase.from('documents').update({ body, updated_at: new Date() }).eq('id', documentId)
  - Show a saving spinner during the update
- Add a '+' button next to 'Documents' heading that opens a Dialog for creating a new document
  - New document form: title field only. Body starts empty.
  - On create, insert into documents and immediately open the new document
- 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.

```
Add workspace member management. Requirements:

1. 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')

2. Add a 'Members' section to workspace settings (a Sheet component triggered from the sidebar gear icon)
   - Show current workspace_members with Avatar, display name, role Badge, and 'Remove' Button (admin only)
   - Show pending invitations with email, sent date, and 'Cancel' Button
   - 'Invite Member' Button opens a Dialog with email Input and role Select (member/admin)
   - On invite submit: insert into workspace_invitations, then invoke the 'send-workspace-invite' Edge Function

3. Create Edge Function supabase/functions/send-workspace-invite/index.ts that:
   - Reads the invitation row by ID
   - Sends an email via Resend with a link: https://yourapp.com/join?token={invitation.token}
   - Returns 200 on success

4. 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 example

File: `src/components/ChannelView.tsx`

```typescript
import { useEffect, useRef, useState, useCallback } from 'react'
import { Send } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'
import { formatDistanceToNow } from 'date-fns'

type Message = { id: string; user_id: string; body: string; created_at: string }
type Props = { channelId: string; workspaceId: string; channelName: string }

export function ChannelView({ channelId, workspaceId, channelName }: Props) {
  const { user } = useAuth()
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState('')
  const [sending, setSending] = useState(false)
  const bottomRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!channelId || !user?.id) return
    supabase.from('messages').select('id,user_id,body,created_at')
      .eq('channel_id', channelId).order('created_at', { ascending: true }).limit(50)
      .then(({ data }) => { setMessages(data ?? []); setTimeout(() => bottomRef.current?.scrollIntoView(), 50) })

    const channel = supabase.channel(`ws:${workspaceId}:ch:${channelId}`)
      .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `channel_id=eq.${channelId}` },
        payload => {
          setMessages(prev => [...prev, payload.new as Message])
          setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
        })
      .subscribe()
    return () => { supabase.removeChannel(channel) }
  }, [channelId, workspaceId, user?.id])

  const sendMessage = useCallback(async () => {
    if (!input.trim() || !user?.id || sending) return
    setSending(true)
    const body = input.trim()
    setInput('')
    await supabase.from('messages').insert({ channel_id: channelId, user_id: user.id, body })
    setSending(false)
  }, [input, channelId, user?.id, sending])

  return (
    <div className="flex flex-col h-full">
      <div className="border-b px-4 py-2.5 flex items-center gap-2">
        <span className="text-muted-foreground">#</span>
        <span className="font-semibold">{channelName}</span>
      </div>
      <ScrollArea className="flex-1 px-4 py-2">
        {messages.map(m => (
          <div key={m.id} className="flex items-start gap-3 py-1.5">
            <Avatar className="h-8 w-8 mt-0.5">
              <AvatarFallback className="text-xs">{m.user_id.slice(0,2).toUpperCase()}</AvatarFallback>
            </Avatar>
            <div>
              <div className="flex items-baseline gap-2">
                <span className="text-sm font-medium">{m.user_id === user?.id ? 'You' : m.user_id.slice(0,8)}</span>
                <span className="text-xs text-muted-foreground">{formatDistanceToNow(new Date(m.created_at), { addSuffix: true })}</span>
              </div>
              <p className="text-sm mt-0.5">{m.body}</p>
            </div>
          </div>
        ))}
        <div ref={bottomRef} />
      </ScrollArea>
      <div className="border-t p-3 flex gap-2">
        <Input value={input} onChange={e => setInput(e.target.value)}
          onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() } }}
          placeholder={`Message #${channelName}`} className="flex-1" />
        <Button size="icon" onClick={sendMessage} disabled={!input.trim() || sending}>
          <Send className="h-4 w-4" />
        </Button>
      </div>
    </div>
  )
}
```

## Common mistakes

- **Putting workspace membership checks inline in every RLS policy** — An inline subquery like 'EXISTS (SELECT 1 FROM workspace_members WHERE ...)' is re-evaluated for each row Postgres scans. On a messages table with millions of rows, this causes severe performance degradation. Fix: 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.
- **Creating separate Realtime channels for messages and Presence** — Each supabase.channel() call counts against your Supabase Realtime connection limit. Two channels per open channel view doubles your connection usage. Fix: 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.
- **Forgetting to remove the user from Presence when the component unmounts** — Presence state persists until the channel is removed. If you navigate away but do not call supabase.removeChannel(), the user appears online indefinitely even after closing the workspace view. Fix: The cleanup in return () => { supabase.removeChannel(channel) } handles both Realtime unsubscribe and Presence untrack automatically when the channel is destroyed.
- **Fetching all channels for the workspace on every render** — The channel list rarely changes. Re-fetching it whenever the active channel changes wastes queries and causes sidebar flicker. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/team-workspace
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/team-workspace
