# How to Build Property management system with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a landlord property management dashboard with V0 using Next.js and Supabase to track units, tenants, leases, rent payments, and maintenance requests. Features real-time occupancy rate calculations, rent roll tracking, and automated overdue payment reminders — all in about 1-2 hours.

## Before you start

- A V0 account (Premium recommended for multiple dashboard pages)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Clerk account for authentication (free tier works)
- Basic understanding of property management (units, leases, rent cycles)

## Step-by-step guide

### 1. Set up the project and property management schema

Open V0 and create a new project. Use the Connect panel to add Supabase and enable Clerk via Vercel Marketplace. Prompt V0 to create the full schema for properties, units, tenants, leases, payments, and maintenance.

```
// Paste this prompt into V0's AI chat:
// Build a property management system. Create a Supabase schema with:
// 1. properties: id (uuid PK), owner_id (uuid FK), name (text), address (text), type (text check residential/commercial), units_count (integer), created_at (timestamptz)
// 2. units: id (uuid PK), property_id (uuid FK), unit_number (text), bedrooms (integer), bathrooms (numeric), rent_amount (integer), status (text check vacant/occupied/maintenance), created_at (timestamptz)
// 3. tenants: id (uuid PK), user_id (uuid nullable), first_name (text), last_name (text), email (text), phone (text), created_at (timestamptz)
// 4. leases: id (uuid PK), unit_id (uuid FK), tenant_id (uuid FK), start_date (date), end_date (date), monthly_rent (integer), deposit (integer), status (text check active/expired/terminated), created_at (timestamptz)
// 5. payments: id (uuid PK), lease_id (uuid FK), amount (integer), payment_date (date), method (text), status (text check paid/pending/overdue/partial), created_at (timestamptz)
// 6. maintenance_requests: id (uuid PK), unit_id (uuid FK), tenant_id (uuid FK nullable), title (text), description (text), priority (text check low/medium/high/emergency), status (text check open/in_progress/completed), created_at (timestamptz)
// RLS: owner_id = auth.uid() for all property-related queries.
// Generate SQL migration and TypeScript types.
```

> Pro tip: Use V0's Connect panel to provision Supabase with the full schema — prompt V0 with the table structure and it generates migrations automatically.

**Expected result:** Supabase is connected with all six tables created. RLS policies ensure landlords only see their own properties, units, tenants, leases, payments, and maintenance requests.

### 2. Build the portfolio overview dashboard

Create the main dashboard showing key metrics across all properties — total units, occupancy rate, monthly revenue, and overdue payments. Use Supabase database views for computed aggregations.

```
// Paste this prompt into V0's AI chat:
// Build a property management dashboard at app/properties/page.tsx.
// Requirements:
// - Protected by Clerk auth
// - Top row of 4 metric Cards:
//   - Total Units (count of units), Occupied (count where status=occupied), Occupancy Rate (percentage), Monthly Revenue (sum of active lease monthly_rent)
// - Property list below as Cards showing: property name, address, units count, occupancy Badge
// - Each property Card is clickable → navigates to app/properties/[id]/page.tsx
// - Use Tabs to switch between: All Properties, Residential, Commercial
// - Use shadcn/ui Badge for property type (residential=blue, commercial=purple)
// - Data fetched server-side using Supabase database views joining units and leases
// - Server Components for all data fetching
```

**Expected result:** A dashboard with four metric Cards (total units, occupied, occupancy rate, revenue) and a filterable list of property Cards with occupancy Badges.

### 3. Create the rent roll and payment tracking page

Build a rent roll page showing all active leases with payment status. This is the core financial view for landlords — which tenants have paid, which are overdue, and the total expected vs. collected revenue.

```
import { createClient } from '@/lib/supabase/server'
import { Card } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'

const statusColors = {
  paid: 'default',
  pending: 'secondary',
  overdue: 'destructive',
  partial: 'outline',
} as const

export default async function PaymentsPage() {
  const supabase = await createClient()

  const { data: leases } = await supabase
    .from('leases')
    .select(`
      id, monthly_rent, status,
      units!inner(unit_number, properties!inner(name)),
      tenants!inner(first_name, last_name, email),
      payments(amount, payment_date, status)
    `)
    .eq('status', 'active')
    .order('created_at', { ascending: false })

  const totalExpected = leases?.reduce((sum, l) => sum + l.monthly_rent, 0) ?? 0
  const totalCollected = leases?.reduce((sum, l) => {
    const paid = l.payments?.filter((p: { status: string }) => p.status === 'paid')
      .reduce((s: number, p: { amount: number }) => s + p.amount, 0) ?? 0
    return sum + paid
  }, 0) ?? 0

  return (
    <div className="p-6 space-y-6">
      <div className="grid grid-cols-3 gap-4">
        <Card className="p-4">
          <p className="text-sm text-muted-foreground">Expected</p>
          <p className="text-2xl font-bold">${(totalExpected / 100).toLocaleString()}</p>
        </Card>
        <Card className="p-4">
          <p className="text-sm text-muted-foreground">Collected</p>
          <p className="text-2xl font-bold">${(totalCollected / 100).toLocaleString()}</p>
        </Card>
        <Card className="p-4">
          <p className="text-sm text-muted-foreground">Outstanding</p>
          <p className="text-2xl font-bold text-destructive">
            ${((totalExpected - totalCollected) / 100).toLocaleString()}
          </p>
        </Card>
      </div>

      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Property</TableHead>
            <TableHead>Unit</TableHead>
            <TableHead>Tenant</TableHead>
            <TableHead>Rent</TableHead>
            <TableHead>Status</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {leases?.map((lease) => (
            <TableRow key={lease.id}>
              <TableCell>{(lease.units as any).properties.name}</TableCell>
              <TableCell>{(lease.units as any).unit_number}</TableCell>
              <TableCell>
                {(lease.tenants as any).first_name} {(lease.tenants as any).last_name}
              </TableCell>
              <TableCell>${(lease.monthly_rent / 100).toLocaleString()}</TableCell>
              <TableCell>
                <Badge variant={statusColors[(lease.payments?.[0] as any)?.status ?? 'pending'] ?? 'secondary'}>
                  {(lease.payments?.[0] as any)?.status ?? 'pending'}
                </Badge>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}
```

**Expected result:** A rent roll page showing expected, collected, and outstanding revenue Cards plus a Table of all active leases with tenant names, rent amounts, and payment status Badges.

### 4. Build the maintenance request queue

Create a maintenance request page where tenants submit requests and landlords manage them through a status workflow (open → in progress → completed). Priority levels help triage emergency repairs.

```
// Paste this prompt into V0's AI chat:
// Build a maintenance request page at app/maintenance/page.tsx.
// Requirements:
// - Show all maintenance requests in a shadcn/ui Table
// - Columns: unit number, tenant name, title, priority Badge (emergency=red, high=orange, medium=blue, low=gray), status Badge (open=yellow, in_progress=blue, completed=green), created_at
// - Filter by status using shadcn/ui Tabs: All, Open, In Progress, Completed
// - Filter by priority using Select dropdown
// - Sort by priority (emergency first) then created_at
// - Clicking a row opens a Dialog with full description and status update controls
// - Status update: Select for new status, Textarea for notes, Button to save
// - "New Request" Button opens Dialog with: Select for unit, Input for title, Textarea for description, Select for priority
// - Server Actions: createMaintenanceRequest(), updateMaintenanceStatus()
// - Use Server Components for data fetching
```

> Pro tip: Use V0's Design Mode (Option+D) to visually adjust the maintenance table row heights, Badge colors, and Dialog widths for free.

**Expected result:** A maintenance queue with filterable Table, priority and status Badges, and a Dialog for updating request status. Emergency requests appear at the top.

### 5. Add the tenant onboarding and lease management flow

Build forms for adding new tenants and creating leases. Include a tenant directory page with search and the lease creation flow that links tenants to units.

```
// Paste this prompt into V0's AI chat:
// Build tenant and lease management:
// 1. Tenant directory at app/tenants/page.tsx:
//    - Searchable Table of all tenants with first_name, last_name, email, phone, active lease unit
//    - "Add Tenant" Button opens Dialog with Input fields for name, email, phone
//    - Clicking a tenant row shows their lease history and payment history
//    - Server Action: createTenant()
// 2. Lease creation at app/properties/[id]/page.tsx:
//    - In the property detail view, each unit Card has an "Add Lease" Button (only for vacant units)
//    - Dialog with: Select for tenant (from tenants table), Calendar for start_date and end_date, Input for monthly_rent and deposit
//    - Creating a lease automatically sets unit status to 'occupied'
//    - AlertDialog for lease termination ("This will mark the unit as vacant")
//    - Server Actions: createLease(), terminateLease()
// 3. Both use Server Components for data fetching, 'use client' for interactive forms
```

**Expected result:** A tenant directory with search and add functionality, plus a lease creation Dialog on each vacant unit that links tenants to units and updates occupancy status.

## Complete code example

File: `app/actions/properties.ts`

```typescript
'use server'

import { createClient } from '@/lib/supabase/server'
import { auth } from '@clerk/nextjs/server'
import { revalidatePath } from 'next/cache'

export async function createUnit(
  propertyId: string,
  data: {
    unit_number: string
    bedrooms: number
    bathrooms: number
    rent_amount: number
  }
) {
  const supabase = await createClient()

  await supabase.from('units').insert({
    property_id: propertyId,
    ...data,
    status: 'vacant',
  })

  revalidatePath(`/properties/${propertyId}`)
}

export async function recordPayment(
  leaseId: string,
  amount: number,
  method: string
) {
  const supabase = await createClient()

  await supabase.from('payments').insert({
    lease_id: leaseId,
    amount,
    payment_date: new Date().toISOString().split('T')[0],
    method,
    status: 'paid',
  })

  revalidatePath('/payments')
}

export async function updateMaintenanceStatus(
  requestId: string,
  status: string
) {
  const supabase = await createClient()

  await supabase
    .from('maintenance_requests')
    .update({ status })
    .eq('id', requestId)

  revalidatePath('/maintenance')
}
```

## Common mistakes

- **Calculating occupancy rates client-side by fetching all units** — Fetching all unit data to the browser is wasteful and exposes potentially sensitive financial information in the network response. Fix: Use Supabase database views that join units and leases server-side and return pre-computed occupancy rates. Only the aggregated number reaches the client.
- **Not updating unit status when creating or terminating a lease** — If a lease is created but the unit still shows as vacant, the occupancy rates are wrong and the unit might get double-booked. Fix: In the createLease Server Action, update both the leases table AND the units table (status='occupied') in the same operation. Do the reverse in terminateLease.
- **Storing rent amounts as floating-point dollars** — Floating-point arithmetic causes rounding errors with money (e.g., $10.10 + $10.20 might not equal $20.30). Fix: Store all monetary amounts as integers in cents. Display by dividing by 100. This eliminates rounding errors in calculations.

## Best practices

- Store all monetary amounts as integers in cents to avoid floating-point rounding errors
- Use Supabase database views for computed metrics like occupancy rates and revenue projections
- Use V0's Connect panel for provisioning Supabase with the full schema — prompt V0 with the table structure for auto-generated migrations
- Set RLS policies ensuring owners only see their own properties (owner_id = auth.uid())
- Use Server Components for all data-heavy pages (rent roll, tenant directory) to keep financial data server-side
- Use V0's Design Mode (Option+D) to visually adjust dashboard Card spacing and Table row heights for free

## Frequently asked questions

### What V0 plan do I need for a property management system?

V0 Free works for the basic build, but Premium ($20/month) is recommended because the system has multiple complex pages (dashboard, rent roll, maintenance, tenants) that benefit from prompt queuing.

### Can tenants access the system to submit maintenance requests?

The base build is landlord-focused. To add a tenant portal, create a separate set of pages with tenant-specific RLS policies that only show their own lease, payments, and the ability to create maintenance requests for their unit.

### How do I handle multiple properties across different locations?

The schema supports multiple properties per owner. The dashboard shows aggregate metrics across all properties, and each property has its own detail page with unit-level management.

### How do I deploy the property management system?

Click Share then Publish to Production in V0. Set CLERK_SECRET_KEY in the Vars tab without NEXT_PUBLIC_ prefix. Supabase credentials are auto-configured from the Connect panel.

### Can I add online rent payment processing?

Yes. Add Stripe via Vercel Marketplace for one-click setup. Create a checkout route that generates a Stripe Payment Intent for the rent amount, and a webhook handler that updates the payment status to paid when confirmed.

### Can RapidDev help build a custom property management platform?

Yes. RapidDev has built 600+ apps including property management systems with tenant portals, online payments, document management, and accounting integrations. Book a free consultation to discuss your needs.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/property-management-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/property-management-system
