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
}
)
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
}
)
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 }
}
)
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
}
)
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
}
)
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 }
}
)
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
}
)
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:
- Track every content touchpoint with UTM parameters
- Store first-touch and last-touch attribution in your database
- Calculate production costs for every piece of content
- Monitor CAC and LTV by content source
- Build a dashboard that shows what content drives revenue
For a SaaS with built-in UTM tracking, attribution, and analytics infrastructure, see tanstackship.com.
Top comments (0)