Startups & ScalingJérémy Marquer

Product-Market Fit: Technical Guide to Validate Your Startup

Complete methodology to achieve Product-Market Fit: metrics, signals, experiments, analytics stack. Sean Ellis framework + concrete examples.

Product-Market Fit: Technical Guide to Validate Your Startup
#Product-Market Fit#Validation#Metrics#Analytics#Startup

Product-Market Fit: Technical Guide to Validate Your Startup

90% of startups fail before Product-Market Fit. Here's the complete guide (technical + methodology) to know if you've found PMF, and how to measure it rigorously.

Product-Market Fit: Technical Definition

Marc Andreessen's Formula

"Product-Market Fit = when your product solves an urgent problem for a specific market, and that market pulls you rather than you pushing it."

Technical Signals:

  • ✅ Cohort retention >40% M1
  • ✅ NPS >40
  • ✅ CAC payback <12 months
  • ✅ Organic growth >20%/month
  • ✅ Churn <5%/month

The 3 Phases Before PMF

Phase 1: Problem-Solution Fit (3-6 months)
│ Goal: Validate that the problem exists
│ Metric: 100 interviews, 10+ early adopters
│
├─► Phase 2: Product-Solution Fit (6-12 months)
│   Goal: Build usable MVP
│   Metric: 10 paying users, retention >30%
│
├─► Phase 3: Product-Market Fit (12-24 months)
    Goal: Scaling channel acquisition
    Metric: 100+ customers, NPS >40, churn <5%

This guide focuses Phase 3: measure and optimize PMF

Sean Ellis Test: Are You PMF?

The Single Question

Survey to Send:

"How would you feel if you could no longer use [Product]?"

  • Very disappointed
  • Somewhat disappointed
  • Not really disappointed
  • N/A (no longer using)

PMF Threshold: >40% answer "Very disappointed"

Technical Setup (TypeScript)

// lib/pmf-survey.ts
import { sendEmail } from './email';

export async function sendPMFSurvey(userId: string) {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  
  // Eligibility: active for 30+ days
  const daysSinceSignup = 
    (Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24);
  if (daysSinceSignup < 30) return;

  // Generate unique token
  const token = generateToken();
  await prisma.pmfSurvey.create({
    data: { userId, token, sentAt: new Date() }
  });

  // Send email
  await sendEmail({
    to: user.email,
    subject: "Quick question about [Product]",
    html: `
      <p>Hi ${user.name},</p>
      <p>We'd love your feedback. How would you feel if you could no longer use [Product]?</p>
      <a href="https://app.com/survey/${token}?answer=very_disappointed">Very disappointed</a><br>
      <a href="https://app.com/survey/${token}?answer=somewhat_disappointed">Somewhat disappointed</a><br>
      <a href="https://app.com/survey/${token}?answer=not_disappointed">Not disappointed</a>
    `
  });
}

Trigger: Daily cron job

// app/api/cron/pmf-survey/route.ts
export async function GET(req: Request) {
  // Auth Vercel Cron
  if (req.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  // Eligible users: active 30+ days, not yet surveyed
  const users = await prisma.user.findMany({
    where: {
      createdAt: { lte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
      pmfSurveys: { none: {} }
    },
    take: 50 // Batch of 50/day
  });

  for (const user of users) {
    await sendPMFSurvey(user.id);
  }

  return Response.json({ sent: users.length });
}

Vercel Cron Config:

// vercel.json
{
  "crons": [{
    "path": "/api/cron/pmf-survey",
    "schedule": "0 10 * * *"
  }]
}

Results Dashboard

// app/admin/pmf/page.tsx
export default async function PMFDashboard() {
  const surveys = await prisma.pmfSurvey.findMany({
    where: { answeredAt: { not: null } }
  });

  const total = surveys.length;
  const veryDisappointed = surveys.filter(s => s.answer === 'very_disappointed').length;
  const score = (veryDisappointed / total) * 100;

  return (
    <div>
      <h1>PMF Score</h1>
      <div className="text-6xl font-bold">
        {score.toFixed(1)}%
      </div>
      <p>{veryDisappointed}/{total} "Very disappointed"</p>
      <p className={score > 40 ? 'text-green-600' : 'text-red-600'}>
        {score > 40 ? '✅ PMF achieved' : '❌ Not yet PMF'}
      </p>
    </div>
  );
}

Complete Analytics Stack for PMF

Layer 1: Product Analytics (Usage)

Tools:

ToolUsagePrice/monthSetup
PostHogAnalytics + feature flags0-50€1h
MixpanelFunnels + cohorts0-100€2h
AmplitudeRetention + behavior0-200€2h

Recommendation: PostHog (open-source, self-hosted possible)

PostHog Setup:

// lib/posthog.ts
import posthog from 'posthog-js';

if (typeof window !== 'undefined') {
  posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
    api_host: 'https://app.posthog.com',
    loaded: (posthog) => {
      if (process.env.NODE_ENV === 'development') posthog.debug();
    }
  });
}

export default posthog;

Tracking Events:

// components/SignupForm.tsx
import posthog from '@/lib/posthog';

function handleSubmit(data) {
  // Create user
  const user = await createUser(data);

  // Track event
  posthog.capture('user_signed_up', {
    userId: user.id,
    plan: user.plan,
    source: data.referralSource
  });

  // Identify user
  posthog.identify(user.id, {
    email: user.email,
    name: user.name,
    createdAt: user.createdAt
  });
}

Critical Events to Track:

// Core activation events
posthog.capture('onboarding_completed');
posthog.capture('first_project_created');
posthog.capture('invited_team_member');
posthog.capture('first_payment');

// Engagement events
posthog.capture('feature_used', { featureName: 'export_pdf' });
posthog.capture('session_started');
posthog.capture('session_ended', { duration: 1200 }); // 20min

Layer 2: Business Metrics (Revenue)

Stripe Dashboard:

// app/api/metrics/mrr/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function GET() {
  // MRR (Monthly Recurring Revenue)
  const subscriptions = await stripe.subscriptions.list({
    status: 'active',
    limit: 100
  });

  const mrr = subscriptions.data.reduce((sum, sub) => {
    return sum + (sub.items.data[0].price.unit_amount! / 100);
  }, 0);

  // Churn rate (last 30 days)
  const canceledSubs = await stripe.subscriptions.list({
    status: 'canceled',
    created: { gte: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60 }
  });

  const churnRate = (canceledSubs.data.length / subscriptions.data.length) * 100;

  return Response.json({ mrr, churnRate });
}

Layer 3: User Feedback (Qualitative)

Tools:

  • Typeform: NPS surveys (0-50€/month)
  • Hotjar: Heatmaps + recordings (0-80€/month)
  • Plain: Customer support + feedback (0-100€/month)

NPS Survey Automation:

// Send NPS survey after 60 days
export async function sendNPSSurvey(userId: string) {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  const daysSinceSignup = 
    (Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24);
  
  if (daysSinceSignup < 60) return;

  await sendEmail({
    to: user.email,
    subject: "How likely are you to recommend [Product]?",
    html: `
      <p>On a scale of 0-10, how likely are you to recommend [Product] to a friend?</p>
      ${[...Array(11)].map((_, i) => 
        `<a href="https://app.com/nps/${token}?score=${i}">${i}</a>`
      ).join(' ')}
    `
  });
}

NPS Calculation:

// NPS = % Promoters (9-10) - % Detractors (0-6)
export function calculateNPS(scores: number[]) {
  const promoters = scores.filter(s => s >= 9).length;
  const detractors = scores.filter(s => s <= 6).length;
  return ((promoters - detractors) / scores.length) * 100;
}

PMF Metrics: Complete Dashboard

Metric #1: Cohort Retention

Definition: % users active N days after signup

// lib/metrics/retention.ts
export async function calculateRetention(cohortDate: Date, dayN: number) {
  // Users signed up that day
  const cohortUsers = await prisma.user.count({
    where: {
      createdAt: {
        gte: cohortDate,
        lt: new Date(cohortDate.getTime() + 24 * 60 * 60 * 1000)
      }
    }
  });

  // Users active N days later
  const activeUsers = await prisma.user.count({
    where: {
      createdAt: {
        gte: cohortDate,
        lt: new Date(cohortDate.getTime() + 24 * 60 * 60 * 1000)
      },
      lastActiveAt: {
        gte: new Date(cohortDate.getTime() + dayN * 24 * 60 * 60 * 1000),
        lt: new Date(cohortDate.getTime() + (dayN + 1) * 24 * 60 * 60 * 1000)
      }
    }
  });

  return (activeUsers / cohortUsers) * 100;
}

Benchmark:

DaySaaS B2BSaaS B2CMarketplace
D160-80%40-60%30-50%
D740-60%20-40%15-30%
D3030-50%10-25%10-20%
D9020-40%5-15%5-15%

PMF Threshold: D30 Retention >40% (B2B) or >25% (B2C)

Metric #2: Churn Rate

Definition: % customers lost/month

export async function calculateChurn(month: Date) {
  const startOfMonth = new Date(month.getFullYear(), month.getMonth(), 1);
  const endOfMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0);

  // Customers at start of month
  const customersStart = await prisma.subscription.count({
    where: {
      status: 'active',
      createdAt: { lt: startOfMonth }
    }
  });

  // Customers who churned this month
  const churned = await prisma.subscription.count({
    where: {
      status: 'canceled',
      canceledAt: {
        gte: startOfMonth,
        lte: endOfMonth
      }
    }
  });

  return (churned / customersStart) * 100;
}

Benchmark:

  • ✅ Excellent: <5%/month
  • ⚠️ Acceptable: 5-10%/month
  • ❌ Bad: >10%/month

PMF Threshold: Churn <5%/month

Metric #3: NPS (Net Promoter Score)

Calculation: % Promoters (9-10) - % Detractors (0-6)

Benchmark:

  • ✅ World-class: >70 (Apple, Tesla)
  • ✅ Excellent: 50-70 (Netflix, Airbnb)
  • ✅ Good: 30-50 (PMF startups)
  • ⚠️ Average: 0-30
  • ❌ Bad: <0

PMF Threshold: NPS >40

Metric #4: CAC Payback

Definition: Time to recover acquisition cost

export function calculateCACPayback(
  cac: number,      // 500€
  arpu: number,     // 50€/month
  grossMargin: number  // 80%
) {
  const monthlyProfit = arpu * (grossMargin / 100);
  return cac / monthlyProfit; // = 12.5 months
}

Benchmark:

  • ✅ Excellent: <6 months
  • ✅ Good: 6-12 months
  • ⚠️ Acceptable: 12-18 months
  • ❌ Bad: >18 months

PMF Threshold: CAC Payback <12 months

Metric #5: Organic Growth

Definition: % signups without paid ads

export async function calculateOrganicGrowth(month: Date) {
  const signups = await prisma.user.count({
    where: {
      createdAt: {
        gte: new Date(month.getFullYear(), month.getMonth(), 1),
        lt: new Date(month.getFullYear(), month.getMonth() + 1, 0)
      }
    }
  });

  const organicSignups = await prisma.user.count({
    where: {
      createdAt: {
        gte: new Date(month.getFullYear(), month.getMonth(), 1),
        lt: new Date(month.getFullYear(), month.getMonth() + 1, 0)
      },
      source: { in: ['organic', 'direct', 'referral'] }
    }
  });

  return (organicSignups / signups) * 100;
}

Benchmark:

  • ✅ Strong PMF: >50% organic
  • ⚠️ Weak PMF: 20-50% organic
  • ❌ No PMF: <20% organic

PMF Threshold: >20% monthly organic growth

PMF Dashboard: React Template

// app/admin/pmf-dashboard/page.tsx
export default async function PMFDashboard() {
  const [retention, churn, nps, cac, organic] = await Promise.all([
    calculateRetention(new Date(), 30),
    calculateChurn(new Date()),
    calculateNPS(),
    calculateCACPayback(),
    calculateOrganicGrowth(new Date())
  ]);

  const pmfScore = 
    (retention > 40 ? 20 : 0) +
    (churn < 5 ? 20 : 0) +
    (nps > 40 ? 20 : 0) +
    (cac < 12 ? 20 : 0) +
    (organic > 20 ? 20 : 0);

  return (
    <div className="grid grid-cols-3 gap-4">
      <MetricCard 
        title="D30 Retention"
        value={`${retention.toFixed(1)}%`}
        target=">40%"
        status={retention > 40 ? 'good' : 'bad'}
      />
      <MetricCard 
        title="Churn"
        value={`${churn.toFixed(1)}%`}
        target="<5%"
        status={churn < 5 ? 'good' : 'bad'}
      />
      <MetricCard 
        title="NPS"
        value={nps.toFixed(0)}
        target=">40"
        status={nps > 40 ? 'good' : 'bad'}
      />
      <MetricCard 
        title="CAC Payback"
        value={`${cac.toFixed(1)} months`}
        target="<12 months"
        status={cac < 12 ? 'good' : 'bad'}
      />
      <MetricCard 
        title="Organic Growth"
        value={`${organic.toFixed(1)}%`}
        target=">20%"
        status={organic > 20 ? 'good' : 'bad'}
      />
      <div className="col-span-3 text-center">
        <h2 className="text-2xl font-bold">PMF Score</h2>
        <div className={`text-8xl font-bold ${
          pmfScore === 100 ? 'text-green-600' : 
          pmfScore >= 60 ? 'text-yellow-600' : 
          'text-red-600'
        }`}>
          {pmfScore}/100
        </div>
        <p>
          {pmfScore === 100 && '🎉 PMF achieved!'}
          {pmfScore >= 60 && pmfScore < 100 && '⚠️ Close to PMF'}
          {pmfScore < 60 && '❌ Not yet PMF'}
        </p>
      </div>
    </div>
  );
}

Experiments to Achieve PMF

AARRR Framework (Pirate Metrics)

Acquisition → Activation → Retention → Referral → Revenue

Optimize Each Step:

1. Acquisition

Hypothesis: "Landing page with demo video increases conversion" Experiment:

  • Variant A: Without video
  • Variant B: With 60s video

Measure: Signup conversion Tool: Vercel A/B testing

// app/page.tsx
import { unstable_flag as flag } from '@vercel/flags/next';

export default function Home() {
  const showVideo = flag('show-video-demo');

  return (
    <>
      <h1>Welcome</h1>
      {showVideo && <VideoDemo src="/demo.mp4" />}
      <SignupCTA />
    </>
  );
}

2. Activation

Hypothesis: "Interactive onboarding increases activation" Experiment:

  • Variant A: Static onboarding
  • Variant B: Step-by-step interactive onboarding

Measure: % users who complete onboarding Target: >60%

3. Retention

Hypothesis: "D+3 email reminder increases D7 retention" Experiment:

  • Cohort A: No email
  • Cohort B: D+3 email

Measure: D7 retention Target: +10% vs control

4. Referral

Hypothesis: "Referral program increases organic signups" Experiment:

  • Offer 1 free month per referral

Measure: % signups via referral Target: >15%

5. Revenue

Hypothesis: "Pricing 49€/month vs 99€/month" Experiment:

  • Cohort A: 49€/month
  • Cohort B: 99€/month

Measure: Trial → paid conversion Target: Maximize LTV (Lifetime Value)

Real Cases: Before/After PMF

✅ Success Case: Notion

Before PMF (2016-2018):

  • D30 Retention: 15%
  • Churn: 12%/month
  • Growth: 100% paid ads

Pivot:

  • Focus template gallery (activation)
  • Web clipper (daily hook)
  • Generous free tier

After PMF (2019+):

  • D30 Retention: 65%
  • Churn: 3%/month
  • Growth: 70% organic
  • Valuation: $10B

❌ Failure Case: Quibi

Launch Metrics (2020):

  • $1.75B raised
  • D7 Retention: 8% (catastrophic)
  • Churn: 90%/month
  • NPS: <0

Problem: No PMF (mobile-only content = fake need) Result: Shutdown 6 months after launch

PMF Analytics Budget

Minimum Stack (0-50 users)

ToolPrice/month
PostHog (self-hosted)0€
Stripe Dashboard0€
Google Forms (surveys)0€
TOTAL0€

Recommended Stack (50-500 users)

ToolPrice/month
PostHog Cloud50€
Typeform30€
Hotjar40€
Plain (support)50€
TOTAL170€

Advanced Stack (500+ users)

ToolPrice/month
Amplitude200€
Segment (CDP)120€
Zendesk100€
Looker (BI)300€
TOTAL720€

Conclusion

PMF is not binary, it's a spectrum (0 → 100).

Minimum Threshold for Series A raise:

  • ✅ D30 Retention >40%
  • ✅ Churn <5%/month
  • ✅ NPS >40
  • ✅ CAC Payback <12 months
  • ✅ Organic Growth >20%

Analytics Setup: 2 weeks dev + 170€/month. ROI: Gain 6-12 months vs intuition.

PMF Audit: I analyze your metrics and tell you if you're ready to scale.


About: Jérémy Marquer has helped 15 startups achieve PMF. Data-driven method, no bullshit.

Share this article