DEV Community

sweet
sweet

Posted on

Content Marketing ROI: Measuring What Matters for SaaS Growth

Most SaaS companies cannot answer the question: "Is our content marketing working?" They track vanity metrics (traffic, social shares) instead of revenue metrics (signups from content, content-assisted conversions, LTV of content-sourced users). This guide covers a systematic approach to content ROI measurement — from first-touch attribution through multi-touch models — using the analytics infrastructure at tanstackship.com.


The Vanity Metrics Trap

Vanity Metric Tells You Doesn't Tell You
Pageviews Someone loaded a page Whether they converted
Time on page They read (or walked away) Whether it influenced a decision
Social shares People liked it Whether it drove any signups
Email subscribers People want updates Whether they will ever pay
SEO rankings Your position in search Whether anyone clicks
Revenue Metric Tells You How to Measure
Content-sourced signups Direct attribution UTM parameters + first-touch model
Content-assisted conversions Influenced pipeline Multi-touch attribution
LTV by content source Long-term value Cohort analysis by source
Content CAC Cost per acquisition Total content spend / attr. conversions

Attribution Models

First-Touch Attribution

// First touch: the first piece of content a user encountered
export const getFirstTouchAttribution = createServerFn({ method: "GET" }).handler(
  async ({ data, context }: { data: { period: string } }) => {
    const result = await context.env.DB.prepare(`
      SELECT
        first_utm_source as source,
        first_utm_campaign as campaign,
        first_utm_content as content,
        COUNT(*) as signups,
        SUM(CASE WHEN subscription_status = 'active' THEN 1 ELSE 0 END) as conversions
      FROM users
      WHERE created_at > datetime('now', ?)
      GROUP BY first_utm_source, first_utm_campaign
      ORDER BY signups DESC
    `).bind(data.period).all()

    return result.results
  }
)
Enter fullscreen mode Exit fullscreen mode

Last-Touch Attribution

// Last touch: the content that drove the final conversion
export const getLastTouchAttribution = createServerFn({ method: "GET" }).handler(
  async ({ data, context }: { data: { period: string } }) => {
    const result = await context.env.DB.prepare(`
      SELECT
        source,
        campaign,
        COUNT(*) as conversions
      FROM utm_clicks
      WHERE user_id IN (
        SELECT id FROM users
        WHERE created_at > datetime('now', ?)
        AND subscription_status = 'active'
      )
      ORDER BY created_at DESC
      LIMIT 1
    `).bind(data.period).all()

    return result.results
  }
)
Enter fullscreen mode Exit fullscreen mode

Content Performance by Funnel Stage

Funnel Stage Content Type Primary Metric Secondary Metric
Top of Funnel (Awareness) Blog posts, guides, comparison pages Organic traffic Time on page, scroll depth
Middle of Funnel (Consideration) Case studies, benchmarks, tutorials Email signups Content downloads, CTR
Bottom of Funnel (Conversion) Pricing comparisons, feature deep-dives Free trial signups Demo requests, purchase
Retention Best practices, updates, templates Active usage Feature adoption, upsells

Cost Tracking

// Track content production costs
export const trackContentCost = createServerFn({ method: "POST" }).handler(
  async ({ data }: { data: {
    contentId: string
    hoursSpent: number
    hourlyRate: number
    distributionCost: number
  }}) => {
    const totalCost = data.hoursSpent * data.hourlyRate + data.distributionCost

    await contentDB.prepare(`
      INSERT INTO content_costs
        (id, content_id, hours_spent, hourly_rate,
         distribution_cost, total_cost, created_at)
      VALUES (?, ?, ?, ?, ?, ?, ?)
    `).bind(
      crypto.randomUUID(),
      data.contentId,
      data.hoursSpent,
      data.hourlyRate,
      data.distributionCost,
      totalCost,
      Date.now()
    ).run()

    return { totalCost }
  }
)
Enter fullscreen mode Exit fullscreen mode

Cost Per Acquisition by Content Type

export const getContentCac = createServerFn({ method: "GET" }).handler(
  async ({}, { context }) => {
    const results = await context.env.DB.prepare(`
      SELECT
        c.content_type,
        SUM(cc.total_cost) as total_cost,
        COUNT(DISTINCT u.id) as attributed_signups,
        SUM(cc.total_cost) / COUNT(DISTINCT u.id) as cac
      FROM content_costs cc
      JOIN content c ON c.id = cc.content_id
      LEFT JOIN users u ON u.first_utm_content = c.slug
      GROUP BY c.content_type
      ORDER BY cac ASC
    `).all()

    return results.results
  }
)
Enter fullscreen mode Exit fullscreen mode

LTV Analysis by Content Source

export const getLtvByContentSource = createServerFn({ method: "GET" }).handler(
  async ({}, { context }) => {
    const results = await context.env.DB.prepare(`
      SELECT
        first_utm_source as source,
        COUNT(*) as users,
        AVG(CASE WHEN status = 'active' THEN mrr ELSE 0 END) as avg_mrr,
        SUM(CASE WHEN status = 'active' THEN mrr ELSE 0 END) as total_mrr
      FROM users u
      LEFT JOIN subscriptions s ON s.user_id = u.id
      WHERE u.created_at > datetime('now', '-180 days')
      GROUP BY first_utm_source
      HAVING users > 5
      ORDER BY total_mrr DESC
    `).all()

    return results.results
  }
)
Enter fullscreen mode Exit fullscreen mode

Content ROI Dashboard

export const getContentROIDashboard = createServerFn({ method: "GET" }).handler(
  async ({}, { context }) => {
    const [performance, cac, ltv] = await Promise.all([
      // Content performance overview
      context.env.DB.prepare(`
        SELECT
          c.title,
          c.slug,
          c.published_at,
          c.views,
          c.avg_read_time,
          at.conversions
        FROM content c
        LEFT JOIN attribution at ON at.content_slug = c.slug
        ORDER BY at.conversions DESC
        LIMIT 20
      `).all(),

      // CAC by content type
      getContentCac(),

      // LTV analysis
      getLtvByContentSource(),
    ])

    return { performance, cac, ltv }
  }
)
Enter fullscreen mode Exit fullscreen mode

Content ROI Benchmarks

Content Type Avg. Cost (per piece) Avg. Signups Avg. CAC Avg. 6mo LTV ROI (6mo)
Tutorial/Guide $2,000 50 $40 $360 9x
Comparison page $1,500 80 $19 $420 22x
Case study $3,000 30 $100 $600 6x
Listicle/Resource $800 20 $40 $300 7.5x
Video tutorial $2,500 40 $63 $480 7.6x
Newsletter $500/month 25/mo $20 $360 18x

Building a Content Calendar with ROI Tracking

export const createContentPlan = createServerFn({ method: "POST" }).handler(
  async ({ data, context }: { data: {
    title: string
    type: string
    targetKeyword: string
    estimatedCost: number
    expectedSignups: number
  }}) => {
    const roiProjection = {
      cac: data.estimatedCost / data.expectedSignups,
      projectedMonthlyRevenue: data.expectedSignups * 29, // $29 avg MRR
      breakEvenMonths: data.estimatedCost / (data.expectedSignups * 29),
      sixMonthReturn: (data.expectedSignups * 29 * 6) / data.estimatedCost,
    }

    // Store in database for tracking
    await context.env.DB.prepare(`
      INSERT INTO content_plan
        (title, type, target_keyword, estimated_cost,
         expected_signups, projected_cac, projected_roi, created_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    `).bind(
      data.title, data.type, data.targetKeyword,
      data.estimatedCost, data.expectedSignups,
      roiProjection.cac, roiProjection.sixMonthReturn,
      Date.now()
    ).run()

    return roiProjection
  }
)
Enter fullscreen mode Exit fullscreen mode

Content ROI Measurement Checklist

  • [ ] UTM parameters on every content link
  • [ ] First-touch attribution tracked in database
  • [ ] Content production costs tracked (time + distribution)
  • [ ] Content-assisted conversions identified (multi-touch)
  • [ ] LTV calculated by content source
  • [ ] CAC monitored per content type
  • [ ] ROI dashboard built and reviewed monthly
  • [ ] Content performance compared to projections
  • [ ] Underperforming content identified and optimized
  • [ ] Best-performing content types doubled down on
  • [ ] Attribution data shared with content team

Conclusion

The difference between content marketing that works and content marketing that is a cost center comes down to measurement. SaaS companies that measure content ROI — by every content piece, by source, by channel — consistently outperform those that track only pageviews and social shares.

The measurement stack is straightforward:

  1. Track every content touchpoint with UTM parameters
  2. Store first-touch and last-touch attribution in your database
  3. Calculate production costs for every piece of content
  4. Monitor CAC and LTV by content source
  5. Build a dashboard that shows what content drives revenue

For a SaaS with built-in UTM tracking, attribution, and analytics infrastructure, see tanstackship.com.

Related Resources

Top comments (0)