I Built a Multi-Tenant Car Dealer SaaS in 2 Weeks — Here's the Stack
I wanted to solve a real problem: used car dealers need websites but can't afford developers. So I built ListKars — a platform where dealers sign up, pick a theme, and get a live website in minutes. Free.
Here's how it works and what I learned.
The Problem
Most preowned car dealers run their business from WhatsApp and OLX. They don't have websites because:
- Custom development costs $500-$5,000+
- WordPress is too complex for non-tech people
- Marketplace listings (OLX, CarDekho) don't build their brand
I wanted to give them a branded website with zero setup cost.
The Stack
Monorepo: Turborepo + pnpm workspaces
Backend: NestJS + Fastify + SWC
Database: PostgreSQL + Drizzle ORM
Frontend: Next.js 15 (App Router) + Tailwind CSS 4
Storage: MinIO (S3-compatible) + Sharp for image optimization
Search: Meilisearch
Cache: Redis
Auth: Google OAuth + JWT + refresh tokens
Deployment: Docker Compose on a VPS + Caddy reverse proxy
CDN: Cloudflare
Architecture: Multi-Tenancy
Single database, tenant_id on every table. Each dealer gets dealer-name.listkars.com. Custom domains supported via a tenant_domains table.
The storefront is one Next.js app that reads x-tenant-slug from the request header (set by Caddy based on subdomain) and renders the right dealer's data.
Request: https://automax-mumbai.listkars.com
→ Caddy extracts subdomain → sets x-tenant-slug header
→ Next.js middleware reads it → fetches tenant data
→ Renders the dealer's theme with their cars
7 Themes, One Codebase
Each theme is a folder with layout.tsx, home-page.tsx, car-detail-page.tsx, and a CSS module. The main page.tsx has an if-else chain:
if (themeName === 'theme-alba') return <AlbaLayout>...</AlbaLayout>
if (themeName === 'theme-carswitch') return <CarSwitchLayout>...</CarSwitchLayout>
// ... 5 more themes
return <ClassicLayout>...</ClassicLayout> // default
Themes range from a simple car grid (Classic) to a full landing page with hero, stats, and showroom section (Alba). Dealers switch themes from their dashboard — no code needed.
Plugin System
Not every dealer needs every feature. So I built a plugin system:
- plugins table: platform-wide available features
- tenant_plugins table: which dealer has what enabled
- PluginGate component on the storefront:
<PluginGate slug="emi-calculator">
<EmiCalculator price={car.price} />
</PluginGate>
If the dealer hasn't enabled the EMI Calculator plugin, it doesn't render. Currently 9 plugins: QR codes, PDF brochure, car compare, video listings, EMI calculator, testimonials, WhatsApp button, lead
notifications, visitor analytics.
Lead Management
This is where the business value is. Every inquiry from the storefront creates a lead:
- Status pipeline: New → Contacted → Interested → Won/Lost
- Follow-up reminders with snooze
- Duplicate detection (same phone + same car within 24h)
- Returning buyer tagging
- CSV export
- Response time tracking
The leads page has tabs for "All Leads" and "Follow-ups Due Today" — because dealers check this on their phone every morning.
Currency & Locale from DB
Each tenant has country and currency_code columns. A shared formatPrice() function uses toLocaleString() with the right locale:
// ₹8,00,000 for Indian dealers
// $125,000 for US dealers
// AED 104,999 for UAE dealers
formatPrice(price, currencyCode, country)
View Tracking That Doesn't Lie
Car views are tracked but with safeguards:
- Same visitor + same car within 30 min = skipped (no refresh inflation)
- Bot user-agents filtered (Googlebot, Bingbot, etc.)
- Dealer's own views skipped (referrer from admin dashboard)
- Unique visitors counted separately from total views
What I'd Do Differently
- Theme system needs abstraction. The page.tsx if-else chain is 700+ lines. Should be a registry pattern.
- Should have used tRPC instead of raw fetch calls between Next.js and NestJS.
- Drizzle ORM is great but the migration story with drizzle-kit in a monorepo is painful. Schema push works, generated migrations often conflict.
- ISR (60s revalidation) on the storefront was a game changer — one line of code, massive performance improvement.
Numbers
- 7 themes
- 9 plugins
- 143 e2e tests
- ~70 files changed per major feature
- Deployed on a single VPS with Docker Compose
Try It
listkars.com — sign up, pick a theme, add a car. Your site is live in 2 minutes.
The onboarding flow has a live preview that updates as you type your business name and pick a theme.
If you're building a multi-tenant SaaS, the biggest lesson: start with the tenant isolation pattern on day one. Retrofitting tenant_id later is painful.
Top comments (0)