Appearance
Architecture
Data Flow
┌─────────────┐
│ PostgreSQL │
│ mtl_rent │
└──────┬──────┘
│
Drizzle ORM (shared schema)
│
┌─────────────┬───────────────┼───────────────┐
│ │ │ │
┌──────┴──────┐ ┌───┴────┐ ┌─────┴─────┐ ┌──────┴──────┐
│ Fastify API │ │ Admin │ │ Scraper │ │ RabbitMQ │
│ :3001 │ │ :3002 │ │ :3003 │ │ (AMQP) │
└──────┬──────┘ └───┬────┘ └───────────┘ └─────────────┘
│ │
┌──────┴──────┐ ┌──┴───────────┐
Browser → │ Next.js │ │ React SPA │
│ :3000 │ │ (Vite+shadcn)│
└─────────────┘ └──────────────┘The Next.js frontend is a pure client — no API routes or server actions for business logic. All data mutations go through the Fastify API. This enables:
- Independent scaling of frontend and backend
- Swagger/OpenAPI documentation for all endpoints
- Future mobile app clients using the same API
- Clean separation of concerns
Services
API (services/api/)
Fastify 5 REST API at port 3001. Handles all business logic: listings CRUD, auth, favorites, inquiries, claims, notes, articles, and neighborhood profiles. JWT auth via httpOnly cookies. Swagger UI at /docs.
Web (services/web/)
Next.js 16 App Router. Pure frontend client — all data via API. Bilingual (FR/EN) with next-intl. Routes under app/[locale]/. Uses shadcn/ui components, TanStack Query, Google Maps.
Admin (services/admin/)
Fastify JSON API at port 3002 + React SPA (built with Vite + Tailwind + shadcn). Sidebar dashboard organized into sections: Pipeline (scraper monitoring, raw listings), Management (listing CRUD with bulk archive/unpublish, inquiries, claims), and Content (articles, neighborhoods). Basic Auth. Pipeline triggers call the scraper's HTTP API (port 3003). Queue monitoring via RabbitMQ Management UI at https://mtl-rent-rabbit.fesenko.net.
Scraper (services/scraper/)
RabbitMQ-based pipeline for scraping rental listings from Kijiji. The scraper runs continuously (not on a fixed schedule), publishing individual items through a chain of queues: scrape → normalize → import → image download → published. The importer handles both new imports and updates to existing listings — comparing fields and applying diffs. Claimed listings (landlord edits) are never overwritten. A re-scrape batch (every 4th cycle) revisits existing listings beyond search page coverage. Staleness checker verifies listings are actually removed from the platform (via HTTP fetch) before archiving (72h threshold). Exposes an HTTP API on port 3003 for admin triggers. Sends hourly Telegram stats summaries with update field breakdowns. Runs via pm2 as a persistent worker. Uses its own scraper.* schema tables. Also hosts the email consumer — renders and sends emails from the email.send RabbitMQ queue via @mtl-rent/email.
Email (packages/email/)
Shared email package (@mtl-rent/email) with 9 React Email templates (bilingual EN/FR), rendered server-side and sent via Resend. Includes a RabbitMQ publisher module used by the API, and a sendEmail function used by the scraper's email consumer. Preview server at port 3100. See Email System for details.
Database
PostgreSQL with Drizzle ORM. Schema in packages/database/src/schema.ts, shared across the monorepo via @mtl-rent/database.
Public Tables
- users — tenant and landlord accounts
- neighborhoods — Montreal reference data (seeded, 85 neighborhoods) with bilingual descriptions, highlights, SEO fields, walkability/transit scores
- neighborhood_stats_snapshots — daily per-neighborhood, per-bedroom price/count snapshots for historical trends
- listings — rental listings with all property details, claimed_by/claimed_at for ownership transfer, archived_at/archive_reason for archival tracking
- articles — bilingual blog posts and wiki/guide pages with markdown content, SEO fields, content_status (draft/published/archived)
- listing_images — photos for each listing
- favorites — tenant saved listings
- inquiries — tenant inquiries on listings without contact info
- listing_claims — landlord claims on scraped listings
- listing_notes — private landlord notes on listings
- postal_areas — FSA (3-char postal code prefix) reference data with boundary polygons (193 Greater Montreal FSAs)
- postal_codes — full 6-digit postal codes with centroids (~101K for Greater Montreal from GeoNames)
Scraper Tables (scraper.* schema)
- scrape_runs — execution record per scrape run
- raw_listings — raw + normalized scraped data, pipeline status
- raw_images — image URLs and download status
- raw_snapshots — full Apollo JSON snapshots for debugging
- scraper_state — persistent key-value store
- dedupe_clusters — cross-platform deduplication (future)
See Schema Reference for full column details.
Authentication
JWT tokens in httpOnly cookies. Two roles: tenant and landlord.
Middleware in services/api/src/middleware/auth.ts:
requireAuth— validates JWT, setsrequest.userrequireLandlord— same as requireAuth + checks role
Admin dashboard uses HTTP Basic Auth (separate from JWT).
Frontend
Next.js 16 App Router with:
- shadcn/ui components (Radix UI + TailwindCSS)
- TanStack React Query for server state
- Central API client (
lib/api.ts) with typed request/response - next-intl for bilingual FR/EN support
- Feature-grouped component organization (
components/listings/,components/map/, etc.) - Google Maps integration for map view
- Landlord Listing Management Page (
/dashboard/listings/[id]) — a per-listing control center for status management, private notes, inquiries, and rental tracking - Blog & Wiki pages with markdown rendering (react-markdown + remark-gfm + @tailwindcss/typography)
- Neighborhood profiles with bilingual content, highlights, walkability/transit scores