Skip to main content
RapidDev - Software Development Agency

How to Build a Property Management System with Lovable

Build a full landlord portal in Lovable that manages properties, tenants, rent payments, and maintenance requests. Admins see all data across every property. Tenants log in and see only their own lease and requests — enforced by separate RLS policies on shared tables. File uploads, Calendar for due dates, and a maintenance request Dialog make it production-ready.

What you'll build

  • Properties DataTable with address, unit count, and occupancy rate
  • Tenant management with lease start/end dates and rent amount
  • Rent payment tracker with Calendar showing upcoming due dates and Badge for paid/overdue status
  • Maintenance request Dialog with title, description, priority, and file upload for photos
  • Tenant self-service portal showing only their lease, payment history, and their own requests
  • Admin view seeing all properties and tenants via separate authenticated RLS policies
  • Badge-coded priority and status labels across all maintenance requests
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate17 min read4-6 hoursLovable Pro plan (for Dev Mode), Supabase Free tierApril 2026RapidDev Engineering Team
TL;DR

Build a full landlord portal in Lovable that manages properties, tenants, rent payments, and maintenance requests. Admins see all data across every property. Tenants log in and see only their own lease and requests — enforced by separate RLS policies on shared tables. File uploads, Calendar for due dates, and a maintenance request Dialog make it production-ready.

What you're building

A property management portal with two user roles sharing the same Supabase tables but seeing different data. Landlord admins can create properties, add tenants, log payments, and triage maintenance requests across their entire portfolio. Tenants log in to a self-service portal to view their lease details, check payment history, and submit new maintenance requests with photo attachments. Row-level security policies enforce the data separation without any application-level filtering tricks.

Final result

A live multi-tenant portal where the admin views a full property portfolio and tenants access a scoped dashboard showing only their own data — all at the same Lovable URL.

Tech stack

LovableAI-assisted UI, Pro plan for Dev Mode file editing
SupabasePostgreSQL, RLS with row-level data isolation, Storage for file uploads
shadcn/uiDataTable, Calendar, Dialog, Badge, Card, Sheet, Tabs, Form
React Hook Form + ZodMaintenance request and tenant form validation
date-fnsLease date formatting and overdue payment calculation

Prerequisites

  • Lovable Pro account (Dev Mode needed for editing Edge Function files)
  • Supabase project with Auth enabled
  • Basic understanding of Supabase RLS policies
  • Familiarity with Lovable's Cloud tab and Secrets management
  • Two test user accounts in Supabase Auth — one admin, one tenant

Build steps

1

Design the schema with role-based RLS

This schema stores all data in shared tables. A profiles table marks each user as landlord or tenant. RLS policies check the role and user_id columns to enforce data isolation automatically — no application-level filtering needed.

supabase_schema.sql
1-- Run in Supabase SQL Editor
2create table public.profiles (
3 id uuid primary key references auth.users(id) on delete cascade,
4 role text not null check (role in ('landlord', 'tenant')),
5 full_name text
6);
7
8create table public.properties (
9 id uuid primary key default gen_random_uuid(),
10 landlord_id uuid not null references public.profiles(id),
11 address text not null,
12 unit_count integer not null default 1,
13 created_at timestamptz not null default now()
14);
15
16create table public.tenants (
17 id uuid primary key default gen_random_uuid(),
18 user_id uuid references public.profiles(id),
19 property_id uuid not null references public.properties(id) on delete cascade,
20 landlord_id uuid not null references public.profiles(id),
21 full_name text not null,
22 email text not null,
23 rent_amount numeric not null,
24 lease_start date not null,
25 lease_end date not null
26);
27
28create table public.payments (
29 id uuid primary key default gen_random_uuid(),
30 tenant_id uuid not null references public.tenants(id) on delete cascade,
31 amount numeric not null,
32 due_date date not null,
33 paid_at timestamptz,
34 status text not null default 'pending' check (status in ('pending','paid','overdue'))
35);
36
37create table public.maintenance_requests (
38 id uuid primary key default gen_random_uuid(),
39 tenant_id uuid not null references public.tenants(id),
40 property_id uuid not null references public.properties(id),
41 title text not null,
42 description text,
43 priority text not null default 'medium' check (priority in ('low','medium','high','urgent')),
44 status text not null default 'open' check (status in ('open','in-progress','resolved')),
45 photo_url text,
46 created_at timestamptz not null default now()
47);
48
49alter table public.properties enable row level security;
50alter table public.tenants enable row level security;
51alter table public.payments enable row level security;
52alter table public.maintenance_requests enable row level security;
53alter table public.profiles enable row level security;
54
55-- Landlord sees all their properties
56create policy "landlord_properties" on public.properties for all to authenticated
57 using (landlord_id = auth.uid()) with check (landlord_id = auth.uid());
58
59-- Tenants see their own record; landlords see all their tenants
60create policy "tenant_self" on public.tenants for select to authenticated
61 using (user_id = auth.uid() or landlord_id = auth.uid());
62create policy "landlord_manage_tenants" on public.tenants for insert to authenticated
63 with check (landlord_id = auth.uid());
64create policy "landlord_update_tenants" on public.tenants for update to authenticated
65 using (landlord_id = auth.uid());
66
67-- Payments: tenant sees own, landlord sees all theirs
68create policy "payment_access" on public.payments for select to authenticated
69 using (tenant_id in (select id from public.tenants where user_id = auth.uid() or landlord_id = auth.uid()));
70create policy "landlord_manage_payments" on public.payments for all to authenticated
71 using (tenant_id in (select id from public.tenants where landlord_id = auth.uid()))
72 with check (tenant_id in (select id from public.tenants where landlord_id = auth.uid()));
73
74-- Maintenance: tenant sees/creates own; landlord sees all
75create policy "maintenance_access" on public.maintenance_requests for select to authenticated
76 using (tenant_id in (select id from public.tenants where user_id = auth.uid() or landlord_id = auth.uid()));
77create policy "tenant_create_maintenance" on public.maintenance_requests for insert to authenticated
78 with check (tenant_id in (select id from public.tenants where user_id = auth.uid()));
79create policy "landlord_update_maintenance" on public.maintenance_requests for update to authenticated
80 using (property_id in (select id from public.properties where landlord_id = auth.uid()));

Pro tip: Create a Supabase database function get_my_role() that returns auth.jwt()->'user_metadata'->>'role' — then your app components can call it once on login and cache the role in React context to avoid repeated round-trips.

Expected result: All five tables appear in Supabase Table Editor with RLS enabled. You can verify the policies in the Policies tab of each table.

2

Scaffold the dual-role app with Lovable

Connect Supabase in Lovable's Cloud tab, then use the prompt below to generate the landlord dashboard and tenant portal with role-based routing.

prompt.txt
1// Lovable prompt — paste into chat
2// Build a property management system with Supabase and two user roles: landlord and tenant.
3// On login, read the user's role from the profiles table.
4// Landlord routes (/admin/*):
5// /admin/properties — DataTable: address, unit_count, occupancy, actions (View, Add Tenant)
6// /admin/tenants — DataTable: name, property, rent_amount, lease_end, payment status Badge
7// /admin/maintenance — DataTable: tenant, property, title, priority Badge, status Badge, actions
8// /admin/payments — Calendar view showing due dates, Badge for paid/overdue/pending
9// Tenant routes (/portal):
10// Single dashboard page: Card with lease details, payment history list, maintenance requests list
11// Button to open a Dialog for new maintenance request (title, description, priority Select, photo upload)
12// Use shadcn/ui throughout. Protect landlord routes — redirect tenant users to /portal.
13// Use Supabase Auth for login/signup.

Pro tip: After Lovable generates the routes, review each data fetch and confirm it uses auth.uid() implicitly through RLS — do not add extra .eq('landlord_id', user.id) filters in your queries. Let RLS do the work.

Expected result: Lovable generates admin and portal route groups, a role-detection hook, and DataTable components for each entity. Preview shows the login form.

3

Build the maintenance request Dialog with file upload

The maintenance request Dialog lets tenants describe the issue, set priority, and optionally upload a photo. The photo goes to a Supabase Storage bucket and the public URL is saved in the photo_url column.

src/components/MaintenanceDialog.tsx
1// src/components/MaintenanceDialog.tsx
2import { useState } from 'react'
3import { useForm } from 'react-hook-form'
4import { zodResolver } from '@hookform/resolvers/zod'
5import { z } from 'zod'
6import { supabase } from '@/lib/supabase'
7import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
8import { Button } from '@/components/ui/button'
9import { Input } from '@/components/ui/input'
10import { Textarea } from '@/components/ui/textarea'
11import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
12import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
13import { toast } from 'sonner'
14
15const schema = z.object({
16 title: z.string().min(5).max(120),
17 description: z.string().max(800).optional(),
18 priority: z.enum(['low', 'medium', 'high', 'urgent'])
19})
20type FormValues = z.infer<typeof schema>
21
22type Props = { tenantId: string; propertyId: string; open: boolean; onClose: () => void; onSaved: () => void }
23
24export function MaintenanceDialog({ tenantId, propertyId, open, onClose, onSaved }: Props) {
25 const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { priority: 'medium' } })
26 const [file, setFile] = useState<File | null>(null)
27 const [uploading, setUploading] = useState(false)
28
29 async function onSubmit(values: FormValues) {
30 setUploading(true)
31 let photoUrl: string | null = null
32 if (file) {
33 const path = `maintenance/${Date.now()}_${file.name}`
34 const { error: upErr } = await supabase.storage.from('maintenance-photos').upload(path, file)
35 if (upErr) { toast.error('Photo upload failed'); setUploading(false); return }
36 const { data } = supabase.storage.from('maintenance-photos').getPublicUrl(path)
37 photoUrl = data.publicUrl
38 }
39 const { error } = await supabase.from('maintenance_requests').insert({
40 ...values, tenant_id: tenantId, property_id: propertyId, photo_url: photoUrl
41 })
42 setUploading(false)
43 if (error) { toast.error('Failed to submit request'); return }
44 toast.success('Maintenance request submitted')
45 form.reset(); setFile(null); onSaved(); onClose()
46 }
47
48 return (
49 <Dialog open={open} onOpenChange={onClose}>
50 <DialogContent className="max-w-md">
51 <DialogHeader><DialogTitle>New Maintenance Request</DialogTitle></DialogHeader>
52 <Form {...form}>
53 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
54 <FormField control={form.control} name="title" render={({ field }) => (
55 <FormItem><FormLabel>Issue</FormLabel><FormControl><Input placeholder="Leaking tap in kitchen" {...field} /></FormControl><FormMessage /></FormItem>
56 )} />
57 <FormField control={form.control} name="description" render={({ field }) => (
58 <FormItem><FormLabel>Details</FormLabel><FormControl><Textarea placeholder="Describe the issue" {...field} /></FormControl><FormMessage /></FormItem>
59 )} />
60 <FormField control={form.control} name="priority" render={({ field }) => (
61 <FormItem><FormLabel>Priority</FormLabel>
62 <Select onValueChange={field.onChange} defaultValue={field.value}>
63 <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
64 <SelectContent>
65 <SelectItem value="low">Low</SelectItem>
66 <SelectItem value="medium">Medium</SelectItem>
67 <SelectItem value="high">High</SelectItem>
68 <SelectItem value="urgent">Urgent</SelectItem>
69 </SelectContent>
70 </Select>
71 <FormMessage /></FormItem>
72 )} />
73 <div className="space-y-1">
74 <label className="text-sm font-medium">Photo (optional)</label>
75 <Input type="file" accept="image/*" onChange={e => setFile(e.target.files?.[0] ?? null)} />
76 </div>
77 <DialogFooter>
78 <Button variant="ghost" type="button" onClick={onClose}>Cancel</Button>
79 <Button type="submit" disabled={uploading}>{uploading ? 'Submitting...' : 'Submit'}</Button>
80 </DialogFooter>
81 </form>
82 </Form>
83 </DialogContent>
84 </Dialog>
85 )
86}

Pro tip: Create the maintenance-photos Storage bucket in Supabase Dashboard → Storage → New Bucket. Set it to public so getPublicUrl() works without signed URLs. Add an RLS policy so only authenticated users can upload.

Expected result: The Dialog renders with all fields. Submitting without a photo creates the request row immediately. Submitting with a photo uploads to Storage first, then saves the URL — the full flow takes 2-3 seconds.

4

Add the payments Calendar with overdue detection

The admin payments view shows a shadcn Calendar with due dates marked. Clicking a date reveals a Sheet with that month's payment list. A status Badge auto-calculates overdue from the due_date vs today.

src/components/PaymentsCalendar.tsx
1// src/components/PaymentsCalendar.tsx
2import { useEffect, useState } from 'react'
3import { Calendar } from '@/components/ui/calendar'
4import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
5import { Badge } from '@/components/ui/badge'
6import { Card, CardContent } from '@/components/ui/card'
7import { supabase } from '@/lib/supabase'
8import { format, isPast, isSameDay } from 'date-fns'
9
10type Payment = { id: string; due_date: string; amount: number; status: string; tenants: { full_name: string } }
11
12export function PaymentsCalendar() {
13 const [payments, setPayments] = useState<Payment[]>([])
14 const [selected, setSelected] = useState<Date | undefined>()
15 const [sheetOpen, setSheetOpen] = useState(false)
16
17 useEffect(() => {
18 supabase.from('payments').select('*, tenants(full_name)').then(({ data }) => setPayments(data ?? []))
19 }, [])
20
21 const dueDates = payments.map(p => new Date(p.due_date))
22
23 function handleSelect(date: Date | undefined) {
24 setSelected(date)
25 if (date) setSheetOpen(true)
26 }
27
28 const dayPayments = selected
29 ? payments.filter(p => isSameDay(new Date(p.due_date), selected))
30 : []
31
32 function statusVariant(p: Payment): 'default' | 'destructive' | 'outline' {
33 if (p.status === 'paid') return 'outline'
34 if (isPast(new Date(p.due_date)) && p.status !== 'paid') return 'destructive'
35 return 'default'
36 }
37
38 function statusLabel(p: Payment) {
39 if (p.status === 'paid') return 'Paid'
40 if (isPast(new Date(p.due_date))) return 'Overdue'
41 return 'Pending'
42 }
43
44 return (
45 <>
46 <Calendar
47 mode="single"
48 selected={selected}
49 onSelect={handleSelect}
50 modifiers={{ hasDue: dueDates }}
51 modifiersClassNames={{ hasDue: 'font-bold underline text-primary' }}
52 />
53 <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
54 <SheetContent>
55 <SheetHeader><SheetTitle>Payments due {selected ? format(selected, 'MMM d, yyyy') : ''}</SheetTitle></SheetHeader>
56 <div className="space-y-3 mt-4">
57 {dayPayments.length === 0 && <p className="text-muted-foreground text-sm">No payments due this day.</p>}
58 {dayPayments.map(p => (
59 <Card key={p.id}><CardContent className="pt-4 flex justify-between items-center">
60 <div><p className="font-medium">{p.tenants?.full_name}</p><p className="text-sm text-muted-foreground">${p.amount}/mo</p></div>
61 <Badge variant={statusVariant(p)}>{statusLabel(p)}</Badge>
62 </CardContent></Card>
63 ))}
64 </div>
65 </SheetContent>
66 </Sheet>
67 </>
68 )
69}

Pro tip: Run a daily Supabase Edge Function with pg_cron to update payment rows from pending to overdue where due_date < now() and paid_at is null. This keeps status accurate without relying on client-side logic.

Expected result: The calendar renders with underlined dates that have payments due. Clicking a date opens a Sheet listing the tenants with their payment amount and a colored Badge showing paid, pending, or overdue.

5

Build the tenant self-service portal

The tenant portal is a single page that shows lease details, payment history, and maintenance requests — all scoped automatically by RLS using the logged-in user's ID. No extra filtering code is needed.

src/pages/TenantPortal.tsx
1// src/pages/TenantPortal.tsx
2import { useEffect, useState } from 'react'
3import { supabase } from '@/lib/supabase'
4import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
5import { Badge } from '@/components/ui/badge'
6import { Button } from '@/components/ui/button'
7import { MaintenanceDialog } from '@/components/MaintenanceDialog'
8import { format } from 'date-fns'
9
10export function TenantPortal() {
11 const [tenant, setTenant] = useState<any>(null)
12 const [payments, setPayments] = useState<any[]>([])
13 const [requests, setRequests] = useState<any[]>([])
14 const [dialogOpen, setDialogOpen] = useState(false)
15
16 async function load() {
17 const { data: { user } } = await supabase.auth.getUser()
18 if (!user) return
19 const { data: t } = await supabase.from('tenants').select('*, properties(address)').eq('user_id', user.id).single()
20 setTenant(t)
21 if (t) {
22 const [p, r] = await Promise.all([
23 supabase.from('payments').select('*').eq('tenant_id', t.id).order('due_date', { ascending: false }),
24 supabase.from('maintenance_requests').select('*').eq('tenant_id', t.id).order('created_at', { ascending: false })
25 ])
26 setPayments(p.data ?? [])
27 setRequests(r.data ?? [])
28 }
29 }
30
31 useEffect(() => { load() }, [])
32
33 if (!tenant) return <p className="p-6 text-muted-foreground">Loading your portal...</p>
34
35 return (
36 <div className="p-6 space-y-6 max-w-2xl">
37 <Card>
38 <CardHeader><CardTitle>Your Lease</CardTitle></CardHeader>
39 <CardContent className="space-y-1">
40 <p><span className="font-medium">Property:</span> {tenant.properties?.address}</p>
41 <p><span className="font-medium">Rent:</span> ${tenant.rent_amount}/mo</p>
42 <p><span className="font-medium">Lease:</span> {format(new Date(tenant.lease_start), 'MMM d, yyyy')} {format(new Date(tenant.lease_end), 'MMM d, yyyy')}</p>
43 </CardContent>
44 </Card>
45 <Card>
46 <CardHeader><CardTitle>Payment History</CardTitle></CardHeader>
47 <CardContent className="space-y-2">
48 {payments.slice(0, 6).map(p => (
49 <div key={p.id} className="flex justify-between text-sm">
50 <span>{format(new Date(p.due_date), 'MMM yyyy')}</span>
51 <Badge variant={p.status === 'paid' ? 'outline' : p.status === 'overdue' ? 'destructive' : 'default'}>{p.status}</Badge>
52 </div>
53 ))}
54 </CardContent>
55 </Card>
56 <Card>
57 <CardHeader className="flex flex-row items-center justify-between">
58 <CardTitle>Maintenance Requests</CardTitle>
59 <Button size="sm" onClick={() => setDialogOpen(true)}>New Request</Button>
60 </CardHeader>
61 <CardContent className="space-y-2">
62 {requests.map(r => (
63 <div key={r.id} className="flex justify-between text-sm">
64 <span>{r.title}</span>
65 <Badge variant="outline">{r.status}</Badge>
66 </div>
67 ))}
68 </CardContent>
69 </Card>
70 {tenant && <MaintenanceDialog tenantId={tenant.id} propertyId={tenant.properties?.id} open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={load} />}
71 </div>
72 )
73}

Pro tip: On first login, check if a profiles row exists for the user and create it if not using upsert. This handles the case where an admin creates a tenant account manually in Supabase Auth.

Expected result: A tenant user logging in sees only their own property address, lease dates, rent amount, payment history, and maintenance requests. No other tenants' data appears — enforced entirely by RLS.

Complete code

src/components/MaintenanceDialog.tsx
1import { useState } from 'react'
2import { useForm } from 'react-hook-form'
3import { zodResolver } from '@hookform/resolvers/zod'
4import { z } from 'zod'
5import { supabase } from '@/lib/supabase'
6import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
7import { Button } from '@/components/ui/button'
8import { Input } from '@/components/ui/input'
9import { Textarea } from '@/components/ui/textarea'
10import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
11import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
12import { toast } from 'sonner'
13
14const schema = z.object({
15 title: z.string().min(5).max(120),
16 description: z.string().max(800).optional(),
17 priority: z.enum(['low', 'medium', 'high', 'urgent'])
18})
19type FormValues = z.infer<typeof schema>
20type Props = { tenantId: string; propertyId: string; open: boolean; onClose: () => void; onSaved: () => void }
21
22export function MaintenanceDialog({ tenantId, propertyId, open, onClose, onSaved }: Props) {
23 const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { priority: 'medium' } })
24 const [file, setFile] = useState<File | null>(null)
25 const [uploading, setUploading] = useState(false)
26
27 async function onSubmit(values: FormValues) {
28 setUploading(true)
29 let photoUrl: string | null = null
30 if (file) {
31 const path = `maintenance/${Date.now()}.${file.name.split('.').pop()}`
32 const { error: upErr } = await supabase.storage.from('maintenance-photos').upload(path, file, { contentType: file.type })
33 if (upErr) toast.error('Photo upload failed')
34 else photoUrl = supabase.storage.from('maintenance-photos').getPublicUrl(path).data.publicUrl
35 }
36 const { error } = await supabase.from('maintenance_requests').insert({
37 ...values, tenant_id: tenantId, property_id: propertyId, photo_url: photoUrl
38 })
39 setUploading(false)
40 if (error) { toast.error('Failed to submit request'); return }
41 toast.success('Maintenance request submitted')
42 form.reset(); setFile(null); onSaved(); onClose()
43 }
44
45 return (
46 <Dialog open={open} onOpenChange={onClose}>
47 <DialogContent className="max-w-md">
48 <DialogHeader><DialogTitle>New Maintenance Request</DialogTitle></DialogHeader>
49 <Form {...form}>
50 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
51 <FormField control={form.control} name="title" render={({ field }) => (
52 <FormItem><FormLabel>Issue summary</FormLabel><FormControl><Input placeholder="e.g. Leaking kitchen tap" {...field} /></FormControl><FormMessage /></FormItem>
53 )} />
54 <FormField control={form.control} name="description" render={({ field }) => (
55 <FormItem><FormLabel>Details (optional)</FormLabel><FormControl><Textarea placeholder="When did it start? Which room?" {...field} /></FormControl><FormMessage /></FormItem>
56 )} />
57 <FormField control={form.control} name="priority" render={({ field }) => (
58 <FormItem><FormLabel>Priority</FormLabel>
59 <Select onValueChange={field.onChange} defaultValue={field.value}>
60 <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
61 <SelectContent>
62 {['low','medium','high','urgent'].map(v => <SelectItem key={v} value={v}>{v.charAt(0).toUpperCase()+v.slice(1)}</SelectItem>)}
63 </SelectContent>
64 </Select>
65 <FormMessage /></FormItem>
66 )} />
67 <div className="space-y-1">
68 <label className="text-sm font-medium">Photo (optional)</label>
69 <Input type="file" accept="image/*" onChange={e => setFile(e.target.files?.[0] ?? null)} />
70 </div>
71 <DialogFooter>
72 <Button variant="ghost" type="button" onClick={onClose}>Cancel</Button>
73 <Button type="submit" disabled={uploading}>{uploading ? 'Submitting...' : 'Submit Request'}</Button>
74 </DialogFooter>
75 </form>
76 </Form>
77 </DialogContent>
78 </Dialog>
79 )
80}

Customization ideas

Automated rent reminders

Schedule a Supabase Edge Function with pg_cron to run 3 days before each payment due_date and send an email reminder to the tenant using Resend, pulling their email from the tenants table.

Contractor assignment

Add a contractors table and an assignee_id column on maintenance_requests. The admin assigns a contractor from a Select dropdown; the contractor gets a scoped view of only their assigned requests.

Digital lease signing

Add a signed_at column and a signature_data text column. Embed a simple canvas drawing component in a Dialog for tenants to sign their lease digitally — store the base64 signature string in Supabase.

Occupancy rate dashboard

Add a computed column or database view that calculates occupancy per property (active tenants / unit_count) and display it as a Progress bar on each property card in the admin view.

Maintenance priority notifications

When a maintenance request is inserted with priority 'urgent', trigger a Supabase Edge Function that sends an SMS via Twilio to the landlord's phone number stored in their profile.

Document storage per tenant

Create a documents table with a Supabase Storage bucket. Tenants upload their rental agreements and ID documents, landlords can view them — each with separate RLS policies on the same bucket.

Common pitfalls

Pitfall: Applying only one RLS policy for both landlords and tenants

How to avoid: Write separate named policies for each operation (SELECT, INSERT, UPDATE, DELETE) and each role. Explicit policies are easier to audit and debug than combined OR conditions.

Pitfall: Uploading photos directly to a private Storage bucket

How to avoid: Use a public bucket for maintenance photos and save the permanent public URL via supabase.storage.from('bucket').getPublicUrl(path).data.publicUrl.

Pitfall: Not creating the profiles row automatically on signup

How to avoid: Add a Supabase Database Webhook on the auth.users table that calls an Edge Function to insert a default profiles row on INSERT, or use Supabase's built-in Auth Hooks.

Pitfall: Hardcoding landlord_id in the client component

How to avoid: Always derive landlord_id from the authenticated user context: const { data: { user } } = await supabase.auth.getUser() then use user.id.

Pitfall: Fetching all payments without pagination

How to avoid: Add .range(0, 49) to payment queries and implement Load More pagination in the DataTable.

Best practices

  • Design RLS policies to be your only access control layer — never add .eq('landlord_id', userId) filters in components as a substitute for proper policies.
  • Use a profiles table linked to auth.users rather than storing role metadata in the JWT — it is easier to update roles without forcing re-login.
  • Create Storage buckets with folder-based paths (maintenance/[tenant_id]/[filename]) so you can write granular bucket policies scoped to each tenant's folder.
  • Add database indexes on foreign key columns (tenant_id, property_id, landlord_id) to keep queries fast as the dataset grows.
  • Use Supabase's built-in Auth email templates for lease invitations so tenants receive a properly branded signup link with their role pre-set.
  • Validate file size and type on the client before uploading to Storage to prevent large files and non-image uploads from hitting the bucket.
  • Keep the tenant portal as a single page with lazy-loaded sections rather than multiple routes — tenants need simple, focused UX.
  • Test both roles in separate incognito windows simultaneously to verify RLS isolation visually before going live.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a property management system with React and Supabase. I have a tenants table with columns: id, user_id, property_id, landlord_id, full_name, rent_amount, lease_start, lease_end. I need RLS policies so that: (1) tenants can only SELECT their own row where user_id = auth.uid(), (2) landlords can SELECT all tenants where landlord_id = auth.uid(), (3) only landlords can INSERT new tenants. Write the complete SQL for all three policies.

Lovable Prompt

Add an occupancy dashboard card to my property management admin page. For each property, show: address, total unit_count, number of active tenants (where lease_end > today), occupancy percentage as a Progress bar, and average rent amount. Fetch this with a single Supabase query using a LEFT JOIN on tenants. Use shadcn Card and Progress components.

Build Prompt

In my Lovable project, add a pg_cron scheduled Edge Function that runs every day at 9am UTC. It should query the payments table for rows where due_date = yesterday and status = 'pending', update their status to 'overdue', then for each one fetch the tenant's email from the tenants table and send a reminder email using the Resend API. Store the RESEND_API_KEY in Lovable's Cloud tab Secrets.

Frequently asked questions

How do I give a new tenant access to their portal?

Create a Supabase Auth user for the tenant via Authentication → Users → Invite User. Then create their tenants table row with the new user's auth ID in the user_id column. The RLS policy automatically grants them access to only their data on next login.

Can multiple landlords use the same app?

Yes. Each landlord has their own profiles row with role 'landlord'. The RLS policies on properties and tenants use landlord_id = auth.uid(), so each landlord sees only their own portfolio. No additional application filtering is needed.

How do I handle tenants who move to a new property?

Update the tenant's property_id to the new property, update lease_start and lease_end dates, and create a new payment schedule. The old maintenance requests remain linked to the previous property_id for historical records.

Why does the tenant portal show no data even after I linked their user_id?

The most common cause is that the user is logged in but the profiles row was not created. Check your Supabase Authentication → Users table and the public.profiles table — there must be a matching row. Also verify the RLS select policy is correct.

How do I deploy this so both landlords and tenants can use it?

Click the Publish icon in Lovable to get a production URL. Share it with both landlords and tenants — the app detects the logged-in user's role from the profiles table and routes them to either /admin or /portal automatically.

Can I add a mobile app for tenants?

The Lovable app is a responsive React web app that works well on mobile browsers. For a native app, you would export the code from Lovable Dev Mode and wrap it in Capacitor or React Native with the same Supabase backend.

Can RapidDev help set up the full multi-role auth and RLS configuration?

Yes. Setting up dual-role RLS correctly is one of the more complex parts of Lovable projects. RapidDev offers hands-on setup sessions for Supabase auth and RLS — details at rapiddev.io.

How do I track which maintenance requests have photos attached?

Filter the maintenance_requests table by photo_url IS NOT NULL in your admin DataTable. Add a paperclip icon column that shows when photo_url is present, linking to the image URL in Supabase Storage.

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.