DEV Community

Cover image for I Built a Multi-Tenant Car Dealer SaaS in 2 Weeks — Here's the Stack
dan
dan

Posted on • Originally published at listkars.com

I Built a Multi-Tenant Car Dealer SaaS in 2 Weeks — Here's the Stack

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

  1. Theme system needs abstraction. The page.tsx if-else chain is 700+ lines. Should be a registry pattern.
  2. Should have used tRPC instead of raw fetch calls between Next.js and NestJS.
  3. Drizzle ORM is great but the migration story with drizzle-kit in a monorepo is painful. Schema push works, generated migrations often conflict.
  4. 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)