I wanted to share the architecture of a marketplace I built and have running in production. It's a pay-per-lead system for service contractors — think Thumbtack or HomeAdvisor, but localized for Quebec, Canada.
The site is live at brancheqc.ca with real contractors, real leads, and real Stripe payments flowing through.
How It Works
- Homeowners fill out a multi-step form describing their project (EV charger installation, heat pump, solar panels)
- Contractors see available leads on their dashboard
- They pay $9–$29 per lead to unlock the homeowner's contact info via Stripe Checkout
- Up to 3 contractors can purchase the same lead (shared lead model)
Simple business model. Clean revenue. No subscriptions needed to make it work.
The Stack
- Next.js 16 — App Router, TypeScript, Tailwind CSS
- Supabase — PostgreSQL + Auth + Realtime Presence
- Stripe — Pay-per-lead checkout with webhook handler
- Resend — 6 branded transactional email templates
- Vercel — Production hosting (free tier)
Total hosting cost: $0/month. All services have free tiers that are more than enough at this stage.
Interesting Architecture Decisions
1. Shared Lead Model with Cap
Each lead can be purchased by up to 3 contractors. The 3rd purchase locks the lead. Cancellations reopen a slot. This required careful concurrent-access handling in the purchase flow.
// Simplified purchase logic
const existingPurchases = await supabase
.from('lead_purchases')
.select('id')
.eq('lead_id', leadId);
if (existingPurchases.data.length >= 3) {
return NextResponse.json({ error: 'Lead fully purchased' }, { status: 409 });
}
2. Multi-Step Form with Conditional Logic
The lead capture form has 4 steps: service type → location + contextual questions → budget + timeline → contact info. The contextual questions change based on which service the homeowner selected.
Server component handles generateMetadata (SEO in French), client component uses useLang() for bilingual FR/EN support.
3. Supabase Realtime Presence for Live Visitors
The admin dashboard uses a Presence channel to show live site visitors in real-time:
const channel = supabase.channel('brancheqc-presence');
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
setVisitorCount(Object.keys(state).length);
});
Supabase Realtime is underrated for this kind of thing. Zero additional infrastructure.
4. Stripe Webhook Idempotency
The webhook handler for checkout.session.completed checks for existing lead_purchases records before processing. Key gotcha: webhooks are mode-specific (live vs test). Signing secrets are different per mode, and creating webhooks via the API (not the dashboard) gives you the secret in the response.
5. Social Proof When You Have None
When you're starting with zero traction, your site feels dead. I built a SocialProofToast component that pulls from /api/recent-leads to show real recent activity. When no recent leads exist, it falls back to random contractor region toasts. The rule: never hardcode fake numbers, but do surface real activity creatively.
6. Rate Limiting Without Redis
Simple in-memory rate limiting — 5 requests/min on lead submission, 3/min on contractor registration:
// In-memory store, no Redis needed at this scale
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
7. Next.js 16 Gotcha — Dynamic Route Params
In Next.js 16, params in dynamic routes is now a Promise<{id: string}> — it must be awaited. This caught me off guard coming from Next.js 14:
// Next.js 16 — params must be awaited
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// ...
}
The Revenue Model
| Plan | Monthly Fee | Per Lead |
|---|---|---|
| Gratuit (Free) | $0 | $29 |
| Pro | $49/mo | $19 |
| Élite | $99/mo | $9 |
Contractors start on the free plan. As they buy more leads, upgrading to Pro or Élite becomes obvious math. The upsell path is built into the product experience.
SEO Strategy That's Working
I wrote 30+ guide articles with FAQ schema (JSON-LD) targeting long-tail keywords like "coût installation borne de recharge Québec" (cost of EV charger installation in Quebec). Each article links to the lead form and relevant service pages.
The sitemap covers all public pages dynamically. robots.ts blocks /tableau-de-bord (dashboard) and /api/ from indexing.
What I'd Do Differently
- Start outreach earlier. The code was the easy part. Getting contractors to sign up required cold outreach to local businesses — that's the real grind.
- Build the email nurture sequence from day one. I still have lifecycle emails missing (day 3 nudge, day 7 urgency, win-back). Should have built them before launch.
- Keep the scope smaller. The bilingual system (FR/EN on every page) doubled the work. Worth it for the Quebec market, but I'd skip it if targeting English-only.
I Packaged It as a Kit
After a few people asked if they could buy the code, I realized the marketplace architecture itself might be more valuable than the leads in any single niche.
I extracted the entire codebase, documented everything, and packaged it as a kit. One config file changes the brand, colors, services, and pricing. Database migrations are ready to go.
The same system works for any service industry — plumbing, legal services, wedding vendors, tutoring, home cleaning. Anywhere there's a service provider and a customer looking for quotes.
It's available at mikaelcote.gumroad.com/l/leadgen-marketplace-kit — $97 launch pricing. You can browse the live demo at brancheqc.ca to see exactly what you're getting.
Happy to dive deeper into any of these patterns. What questions do you have about the architecture?
Top comments (0)