Property management, simplified.
A demo tenant management dashboard for landlords - built to continuously explore modern full-stack TypeScript architecture with a focus on clean patterns over complexity. Not intended for production use.
- Portfolio Dashboard: KPI stat cards (properties, occupancy, revenue, open requests), occupancy pie chart, 12-month revenue vs. expenses area chart, and a clickable activity feed
- Property Management: Grid view with occupancy badges, full CRUD (create, edit, delete) via modal forms with Zod validation
- Property Detail: Per-property view with tabbed panels: Units, Financials (revenue / expenses / NOI), and Maintenance history. Inline edit and delete actions.
- Tenant Directory: Stat cards (total, active leases, expiring soon, monthly revenue) above a sortable, filterable table. Click any row to open a detail sheet with edit and delete actions.
- Maintenance Tracker: Filterable requests table across all properties. Click any row to open a detail modal with status transitions, notes, and delete.
- Global Search:
⌘Kto search across properties, tenants, and maintenance requests with keyboard navigation - Settings: Vertical sidebar nav with Profile, Account, Preferences (timezone, date format, currency), and Notification toggles. Persisted to localStorage.
- Dark Mode: System-aware, togglable via sidebar, no flash on load
- Bun ≥ 1.0
git clone https://github.com/yourusername/mothership.git
cd mothership
bun install
bun run db:migrate # create SQLite tables
bun run db:seed # populate with demo data
bun devOpen http://localhost:3000.
bun run test # run all 170 unit tests (Vitest + jsdom)
bun run test:watch # run tests in watch mode
bun run test:ui # open Vitest UI in browser
bun run type-check # TypeScript strict check
bun run build # production build
bun run db:studio # open Drizzle Studio (visual DB browser)| Concern | Choice | Why |
|---|---|---|
| Framework | Next.js 16 App Router | RSC-first: server components fetch data directly, no API boilerplate |
| Database | SQLite via @libsql/client |
Zero infra locally, drop-in path to Turso for production |
| ORM | Drizzle ORM | Schema-as-types, composable queries |
| UI | shadcn/ui + Radix UI | Owned components, accessible by default |
| Styling | Tailwind CSS v4 | Constraint-based design system, no CSS files to maintain |
| Forms | React Hook Form + Zod | Uncontrolled performance + schema-first validation |
| Charts | Recharts | Composable SVG, tree-shakeable |
| Testing | Vitest + Testing Library | Jest-compatible API, Vite-native speed |
Page (async Server Component)
→ query function in db/queries/
→ Drizzle ORM → SQLite
→ typed data passed as props to components
Client Component (form, sortable table)
→ user interaction
→ Server Action in actions/
→ Drizzle mutation → SQLite
→ revalidatePath() → Next.js re-renders the page
No API routes. No client-side data fetching library. The entire read/write cycle is type-safe end to end.
The rule is simple: a component is a Client Component only when it needs state, event handlers, or browser-only APIs (like the Recharts canvas). Everything else is a Server Component, which keeps the client JS bundle minimal.
Client components: AppSidebar, GlobalSearch, ThemeToggle, OccupancyChart, QuickActions, RevenueChart, PropertyForm, AddPropertyModal, PropertyDetailActions, TenantsTable, TenantForm, AddTenantModal, EditTenantModal, TenantDetailSheet, RequestsTable, MaintenanceForm, AddMaintenanceModal, MaintenanceRequestModal, Pagination
Everything else: Server Component - rendered on the server with fresh data.
mothership/
├── app/
│ ├── layout.tsx # Root: ThemeProvider, fonts, metadata
│ ├── not-found.tsx # Custom 404
│ └── (dashboard)/ # Route group: shared sidebar layout
│ ├── layout.tsx # AppShell with collapsible sidebar
│ ├── loading.tsx # Skeleton loading state
│ ├── page.tsx # / - Dashboard
│ ├── properties/ # /properties and /properties/[id]
│ ├── tenants/ # /tenants
│ ├── maintenance/ # /maintenance
│ └── settings/ # /settings
├── actions/ # Server Actions (create, update, delete, search)
├── components/
│ ├── ui/ # shadcn/ui primitives (auto-generated, untouched)
│ ├── layout/ # AppSidebar, GlobalSearch, ThemeToggle
│ ├── dashboard/ # StatCard, RevenueChart, OccupancyChart, ActivityFeed, QuickActions
│ ├── properties/ # PropertyCard, PropertyForm, AddPropertyModal, OccupancyBadge
│ ├── property-detail/ # PropertyDetailActions, PropertyFinancials, PropertyMaintenance, UnitsList
│ ├── tenants/ # TenantsTable, TenantForm, TenantDetailSheet, AddTenantModal, EditTenantModal
│ ├── maintenance/ # RequestsTable, MaintenanceForm, MaintenanceRequestModal, PriorityBadge, StatusBadge
│ └── shared/ # Pagination
├── db/
│ ├── schema.ts # Drizzle schema: single source of truth for all types
│ ├── index.ts # DB client singleton
│ ├── seed.ts # Demo data seed script
│ └── queries/ # Read-only query functions (dashboard, properties, tenants, maintenance, units)
├── lib/ # Pure utilities: formatters, Zod schemas, constants
└── __tests__/ # Mirrors component structure
├── components/
│ ├── dashboard/
│ ├── properties/
│ ├── property-detail/
│ ├── tenants/
│ ├── maintenance/
│ ├── layout/
│ └── shared/
└── lib/
Money in cents. All monetary values (monthlyRent, monthlyRevenue, securityDeposit) are stored as integer cents. This eliminates floating-point arithmetic errors (0.1 + 0.2 !== 0.3). A formatCurrency(cents) function handles display. This is the same pattern used by Stripe.
Computed leaseStatus. Rather than storing a leaseStatus enum that would go stale, the status is derived at query time from leaseEnd via computeLeaseStatus(). Eliminates an entire class of data consistency bugs.
z.number() + valueAsNumber over z.coerce. Zod v4's z.coerce.number() resolves the input type to unknown, which conflicts with React Hook Form's resolver types. Using z.number() with valueAsNumber on number inputs keeps types clean end-to-end without any workarounds.
shadcn/ui components are never modified. Extension happens through wrapper components (AddPropertyModal wraps a Dialog wrapping a PropertyForm). This survives future bunx shadcn add updates without merge conflicts.
Mothership uses SQLite locally via @libsql/client. To deploy with a persistent cloud database, swap to Turso - it's libSQL-compatible with zero code changes:
- Create a Turso database and get your URL + auth token
- Set
TURSO_DATABASE_URLandTURSO_AUTH_TOKENin your Vercel environment variables - Done - the same Drizzle queries run against Turso
- Stripe integration for rent collection and payment tracking
- Lease PDF generation via
@react-pdf/renderer - Email notifications via Resend (lease renewals, overdue rent)
- Optimistic UI for Server Action mutations
- Vercel Analytics integration