<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mikael Cote</title>
    <description>The latest articles on DEV Community by Mikael Cote (@mkcvte).</description>
    <link>https://dev.to/mkcvte</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3845309%2Fb55bbe8d-d67a-40bb-a59d-cb9e2e4b451a.png</url>
      <title>DEV Community: Mikael Cote</title>
      <link>https://dev.to/mkcvte</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mkcvte"/>
    <language>en</language>
    <item>
      <title>I Built a Production Pay-Per-Lead Marketplace with Next.js 16 + Supabase + Stripe</title>
      <dc:creator>Mikael Cote</dc:creator>
      <pubDate>Thu, 26 Mar 2026 22:12:47 +0000</pubDate>
      <link>https://dev.to/mkcvte/i-built-a-production-pay-per-lead-marketplace-with-nextjs-16-supabase-stripe-596j</link>
      <guid>https://dev.to/mkcvte/i-built-a-production-pay-per-lead-marketplace-with-nextjs-16-supabase-stripe-596j</guid>
      <description>&lt;p&gt;I wanted to share the architecture of a marketplace I built and have running in production. It's a &lt;strong&gt;pay-per-lead system&lt;/strong&gt; for service contractors — think Thumbtack or HomeAdvisor, but localized for Quebec, Canada.&lt;/p&gt;

&lt;p&gt;The site is live at &lt;a href="https://brancheqc.ca" rel="noopener noreferrer"&gt;brancheqc.ca&lt;/a&gt; with real contractors, real leads, and real Stripe payments flowing through.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Homeowners&lt;/strong&gt; fill out a multi-step form describing their project (EV charger installation, heat pump, solar panels)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contractors&lt;/strong&gt; see available leads on their dashboard&lt;/li&gt;
&lt;li&gt;They pay &lt;strong&gt;$9–$29 per lead&lt;/strong&gt; to unlock the homeowner's contact info via Stripe Checkout&lt;/li&gt;
&lt;li&gt;Up to &lt;strong&gt;3 contractors&lt;/strong&gt; can purchase the same lead (shared lead model)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Simple business model. Clean revenue. No subscriptions needed to make it work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; — App Router, TypeScript, Tailwind CSS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; — PostgreSQL + Auth + Realtime Presence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; — Pay-per-lead checkout with webhook handler&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend&lt;/strong&gt; — 6 branded transactional email templates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt; — Production hosting (free tier)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total hosting cost: &lt;strong&gt;$0/month&lt;/strong&gt;. All services have free tiers that are more than enough at this stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interesting Architecture Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Shared Lead Model with Cap
&lt;/h3&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified purchase logic&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingPurchases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lead_purchases&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lead_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingPurchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lead fully purchased&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Multi-Step Form with Conditional Logic
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Server component handles &lt;code&gt;generateMetadata&lt;/code&gt; (SEO in French), client component uses &lt;code&gt;useLang()&lt;/code&gt; for bilingual FR/EN support.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Supabase Realtime Presence for Live Visitors
&lt;/h3&gt;

&lt;p&gt;The admin dashboard uses a Presence channel to show live site visitors in real-time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;brancheqc-presence&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;presence&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;presenceState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;setVisitorCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supabase Realtime is underrated for this kind of thing. Zero additional infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Stripe Webhook Idempotency
&lt;/h3&gt;

&lt;p&gt;The webhook handler for &lt;code&gt;checkout.session.completed&lt;/code&gt; checks for existing &lt;code&gt;lead_purchases&lt;/code&gt; records before processing. Key gotcha: &lt;strong&gt;webhooks are mode-specific&lt;/strong&gt; (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.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Social Proof When You Have None
&lt;/h3&gt;

&lt;p&gt;When you're starting with zero traction, your site feels dead. I built a &lt;code&gt;SocialProofToast&lt;/code&gt; component that pulls from &lt;code&gt;/api/recent-leads&lt;/code&gt; to show real recent activity. When no recent leads exist, it falls back to random contractor region toasts. The rule: &lt;strong&gt;never hardcode fake numbers&lt;/strong&gt;, but do surface real activity creatively.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Rate Limiting Without Redis
&lt;/h3&gt;

&lt;p&gt;Simple in-memory rate limiting — 5 requests/min on lead submission, 3/min on contractor registration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In-memory store, no Redis needed at this scale&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rateLimitStore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;resetTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  7. Next.js 16 Gotcha — Dynamic Route Params
&lt;/h3&gt;

&lt;p&gt;In Next.js 16, &lt;code&gt;params&lt;/code&gt; in dynamic routes is now a &lt;code&gt;Promise&amp;lt;{id: string}&amp;gt;&lt;/code&gt; — it must be awaited. This caught me off guard coming from Next.js 14:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Next.js 16 — params must be awaited&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Revenue Model
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Monthly Fee&lt;/th&gt;
&lt;th&gt;Per Lead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gratuit (Free)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;$49/mo&lt;/td&gt;
&lt;td&gt;$19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Élite&lt;/td&gt;
&lt;td&gt;$99/mo&lt;/td&gt;
&lt;td&gt;$9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO Strategy That's Working
&lt;/h2&gt;

&lt;p&gt;I wrote &lt;strong&gt;30+ guide articles&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The sitemap covers all public pages dynamically. &lt;code&gt;robots.ts&lt;/code&gt; blocks &lt;code&gt;/tableau-de-bord&lt;/code&gt; (dashboard) and &lt;code&gt;/api/&lt;/code&gt; from indexing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start outreach earlier.&lt;/strong&gt; The code was the easy part. Getting contractors to sign up required cold outreach to local businesses — that's the real grind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the email nurture sequence from day one.&lt;/strong&gt; I still have lifecycle emails missing (day 3 nudge, day 7 urgency, win-back). Should have built them before launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the scope smaller.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  I Packaged It as a Kit
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same system works for any service industry&lt;/strong&gt; — plumbing, legal services, wedding vendors, tutoring, home cleaning. Anywhere there's a service provider and a customer looking for quotes.&lt;/p&gt;

&lt;p&gt;It's available at &lt;a href="https://mikaelcote.gumroad.com/l/leadgen-marketplace-kit" rel="noopener noreferrer"&gt;mikaelcote.gumroad.com/l/leadgen-marketplace-kit&lt;/a&gt; — $97 launch pricing. You can browse the live demo at &lt;a href="https://brancheqc.ca" rel="noopener noreferrer"&gt;brancheqc.ca&lt;/a&gt; to see exactly what you're getting.&lt;/p&gt;




&lt;p&gt;Happy to dive deeper into any of these patterns. What questions do you have about the architecture?&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>supabase</category>
      <category>stripe</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
