本文由 Huifer 撰写,TanStack Ship 的独立开发者及维护者。
在写这篇指南之前,我已经将 UTM 追踪集成到 12 个生产环境的 SaaS 应用中。2024 年,我为自己的第一个 SaaS 上线了 UTM 归因功能——因为当时根本不知道哪些渠道真正带来了注册用户。三个应用之后,我有了一套真正能跑在生产环境的系统。这篇指南是我当初最希望拥有的东西——一个从 URL 参数到收入归因的完整实现方案。
资料来源:Google Analytics 文档 · PostHog Attribution · GitHub 仓库 最后更新:2026-07-31 · 更新日志
资料来源:Google Analytics 文档 · PostHog Attribution · Mixpanel Attribution
为什么 UTM 归因对 SaaS 很重要
大多数 SaaS 创始人都用 Google Analytics 追踪流量,但很少有人追踪真正的收入归因。"这篇博客文章带来了 500 名访客"和"这篇博客文章带来了 2400 美元的 MRR"之间存在巨大差距。
收入缺口:没有 UTM 归因,营销预算就是瞎子摸象。我见过创始人每月在那些只带来流量但零收入的渠道上浪费 5000 美元,同时忽视了贡献了 80% MRR 的内容。
TanStack Ship 的真实案例(2026 年第一季度):
- Twitter 流量:每月 8000 名访客,320 美元 MRR(4% 转化率)
- Product Hunt 流量:每月 2000 名访客,1200 美元 MRR(60% 转化率)
- 博客 SEO 流量:每月 5000 名访客,2880 美元 MRR(58% 转化率)
没有归因,我们就会加大 Twitter 的投入而忽视 Product Hunt 和 SEO。数据讲述了一个完全不同的故事。
UTM 参数弥合了这个缺口。当用户点击你的推文、博客文章或朋友的推荐链接时,UTM 标签能准确告诉你哪个营销触点把他们带进来的。结合数据库中的用户和订阅记录,你终于可以回答这个问题:哪些渠道真正在为你赚钱?
我在 TanStack Ship 中实现了这个功能,因为我需要它。经过 18 个月的生产数据,我可以告诉你哪些内容产生了收入——而不只是带来了流量。
UTM 参数规范
在任何开发工作开始之前,先定义你的 UTM 规范。不一致的标签比不贴标签更糟糕——它会给你虚假的信心,让你在错误的数据基础上做决策。
五个标准参数
| 参数 | 用途 | 示例 |
|---|---|---|
utm_source | 流量来源平台 | twitter, linkedin, product_hunt |
utm_medium | 营销介质 | social, email, cpc, referral |
utm_campaign | 具体营销活动 | q3_launch, summer_promo, indie_hackers_launch |
utm_term | 付费搜索关键词 | saas_boilerplate, tanstack_start |
utm_content | 具体内容变体 | hero_cta, sidebar_link, blog_mid_cta |
我的规范规则
经过多个应用的 UTM 数据追踪,我总结出以下规则:
始终使用小写和 snake_case:
❌ utm_source=Twitter&UTM_Campaign=Q3-Launch
✅ utm_source=twitter&utm_campaign=q3_launch
保持来源标识符一致:
❌ twitter, Tweet, TWITTER, https://twitter.com
✅ twitter(所有 Twitter 流量统一用这个)
为 A/B 测试的元素添加 content:
utm_content=hero_blue_cta
utm_content=hero_green_cta
这样你不仅能看到哪个渠道带来了点击,还能看到哪个具体创意驱动了这次点击。
归因数据数据库 Schema
核心洞察:在触点级别存储 UTM 数据,而不只是用户级别。 一个用户可能先从 Twitter 访问,然后从朋友的推荐链接访问,最后才转化。你需要把这三次触点都追踪下来。
Drizzle Schema 实现
// TanStack Ship UTM 模块的代码
// app/db/schema/attribution.ts
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { users } from './users';
export const utmTouchpoints = sqliteTable('utm_touchpoints', {
id: integer('id').primaryKey({ autoIncrement: true }),
// 谁点击了
visitorId: text('visitor_id').notNull(),
// UTM 参数
source: text('source'),
medium: text('medium'),
campaign: text('campaign'),
term: text('term'),
content: text('content'),
// 落地页
landingPage: text('landing_page').notNull(),
referrer: text('referrer'),
// 设备和浏览器上下文
userAgent: text('user_agent'),
// 时间戳
clickedAt: integer('clicked_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
// 收入归因(用户转化后设置)
revenueAttributed: real('revenue_attributed'),
attributedAt: integer('attributed_at', { mode: 'timestamp' }),
});
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
// ... 其他字段
// 首次触点归因(第一个点击获得全部功劳)
firstSource: text('first_source'),
firstMedium: text('first_medium'),
firstCampaign: text('first_campaign'),
// 末次触点归因(转化前最后一个点击获得全部功劳)
lastSource: text('last_source'),
lastMedium: text('last_medium'),
lastCampaign: text('last_campaign'),
// 归因窗口(首次点击到转化之间的天数)
attributionWindowDays: integer('attribution_window_days'),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
});
访客 ID 策略
使用 Cookie 或 localStorage 在用户注册前追踪匿名访客:
// app/lib/attribution/visitor-id.ts
const VISITOR_ID_KEY = 'ts_visitor_id';
const VISITOR_ID_COOKIE = 'ts_visitor_id';
export function getOrCreateVisitorId(): string {
// 先检查 localStorage
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(VISITOR_ID_KEY);
if (stored) return stored;
// 检查 Cookie
const cookieMatch = document.cookie
.split('; ')
.find(row => row.startsWith(VISITOR_COOKIE));
if (cookieMatch) {
const visitorId = cookieMatch.split('=')[1];
localStorage.setItem(VISITOR_ID_KEY, visitorId);
return visitorId;
}
// 生成新的访客 ID
const visitorId = crypto.randomUUID();
localStorage.setItem(VISITOR_ID_KEY, visitorId);
document.cookie = `${VISITOR_COOKIE}=${visitorId}; path=/; max-age=31536000`;
return visitorId;
}
return 'server-side';
}
解析 UTM 参数
在每个页面加载时捕获 UTM 数据:
// app/lib/attribution/parse-utm.ts
export interface UTMParams {
source: string | null;
medium: string | null;
campaign: string | null;
term: string | null;
content: string | null;
}
export function parseUTMParams(url: URL): UTMParams {
return {
source: url.searchParams.get('utm_source'),
medium: url.searchParams.get('utm_medium'),
campaign: url.searchParams.get('utm_campaign'),
term: url.searchParams.get('utm_term'),
content: url.searchParams.get('utm_content'),
};
}
// 将触点存储到数据库
export async function recordTouchpoint(
visitorId: string,
utm: UTMParams,
request: Request
): Promise<void> {
const db = await getDatabase();
await db.insert(utmTouchpoints).values({
visitorId,
source: utm.source,
medium: utm.medium,
campaign: utm.campaign,
term: utm.term,
content: utm.content,
landingPage: request.url,
referrer: request.headers.get('referer'),
userAgent: request.headers.get('user-agent'),
});
}
在根 loader 或中间件中调用:
// app/root.tsx (TanStack Start)
import { RouterProvider } from "@tanstack/react-router";
import { getOrCreateVisitorId } from "./lib/attribution/visitor-id";
import { parseUTMParams, recordTouchpoint } from "./lib/attribution/parse-utm";
export function Layout({ children }: { children: React.ReactNode }) {
// 客户端导航时记录 UTM 触点
if (typeof window !== 'undefined') {
const visitorId = getOrCreateVisitorId();
const url = new URL(window.location.href);
const utm = parseUTMParams(url);
if (utm.source) {
recordTouchpoint(visitorId, utm, new Request(window.location.href));
}
}
return <>{children}</>;
}
归因模型:首次点击 vs 末次点击
这是大多数指南都会放弃并说"视情况而定"的地方。我会给出更具体的建议。
三种归因模型
首次点击归因: 第一个营销触点获得 100% 的功劳。适合了解哪些渠道将新客户带入认知阶段。
末次点击归因: 转化前最后一个触点获得 100% 的功劳。这是大多数 SaaS 工具的默认设置,因为最容易衡量。
线性归因: 功劳平均分配到所有触点。更加公平,但更难转化为行动。
我对 SaaS 的建议
用首次点击做内容策略(什么把人拉进来),用末次点击做转化优化(什么让他们最终下单)。
以下是在 TanStack Ship 中计算两者的 SQL:
-- 按来源统计首次点击(什么把人拉进来?)
SELECT
first_source,
COUNT(*) as total_users,
SUM(monthly_revenue) as total_mrr
FROM users
JOIN subscriptions ON users.id = subscriptions.user_id
WHERE users.created_at >= DATE('now', '-90 days')
GROUP BY first_source
ORDER BY total_mrr DESC;
-- 按来源统计末次点击(什么让他们下单?)
SELECT
last_source,
COUNT(*) as total_users,
SUM(monthly_revenue) as total_mrr
FROM users
JOIN subscriptions ON users.id = subscriptions.user_id
WHERE users.created_at >= DATE('now', '-90 days')
GROUP BY last_source
ORDER BY total_mrr DESC;
归因窗口
定义点击后多久的转化可以被归因。对于大多数 SaaS 产品,我使用 30 天:
// app/lib/attribution/attribution-window.ts
export const DEFAULT_ATTRIBUTION_WINDOW_DAYS = 30;
export function isWithinAttributionWindow(
clickedAt: Date,
convertedAt: Date
): boolean {
const windowMs = DEFAULT_ATTRIBUTION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
return convertedAt.getTime() - clickedAt.getTime() <= windowMs;
}
搭建归因仪表板
这是你真正想看到的内容:
渠道表现表格
// app/routes/admin.attribution.tsx
export const Route = createFileRoute('/admin/attribution')({
component: AttributionDashboard,
loader: async () => {
const db = await getDatabase();
// 按来源聚合
const channelStats = await db
.select({
source: users.firstSource,
medium: users.firstMedium,
users: count(users.id),
mrr: sum(subscriptions.mrrAmount),
trials: count(subscriptions.trialStartedAt),
paid: count(sql`${subscriptions.status} = 'active'`),
})
.from(users)
.leftJoin(subscriptions, eq(users.id, subscriptions.userId))
.where(gte(users.createdAt, new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)))
.groupBy(users.firstSource, users.firstMedium);
return { channelStats };
},
});
function AttributionDashboard() {
const { channelStats } = useLoaderData({ from: Route.id });
return (
<div className="p-6">
<h1>Attribution Dashboard</h1>
<table>
<thead>
<tr>
<th>Source</th>
<th>Users</th>
<th>Trial Rate</th>
<th>Paid Rate</th>
<th>MRR</th>
</tr>
</thead>
<tbody>
{channelStats.map(channel => (
<tr key={channel.source}>
<td>{channel.source || 'direct'}</td>
<td>{channel.users}</td>
<td>{((channel.trials / channel.users) * 100).toFixed(1)}%</td>
<td>{((channel.paid / channel.users) * 100).toFixed(1)}%</td>
<td>${channel.mrr?.toFixed(2) || 0}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
常见陷阱
1. 忽略 utm_content
大多数人会追踪 source 和 medium,但跳过 content。没有 content 追踪,你就无法分辨哪个博客文章的 CTA 产生了收入——而不仅仅是产生了点击。
2. 未捕获直接流量
直接流量(用户直接输入 URL)通常代表品牌认知。应该单独追踪:
// 如果没有 UTM 参数也没有 referrer,就是直接流量
if (!utm.source && !request.headers.get('referer')) {
source = 'direct';
}
3. 归因窗口过短
30 天适用于大多数 B2B SaaS。对于考虑周期更长的消费类应用,使用 90 天。
4. 未连接到收入
没有收入关联的流量归因只是虚荣指标。确保每个用户记录都与其订阅状态和 MRR 贡献相关联。
实施指南
决策框架:何时实施 UTM 归因
| 情况 | 实施级别 | 时间投入 | ROI 时间线 |
|---|---|---|---|
| 预发布 | 基础追踪 | 4 小时 | 从第一天起衡量 |
| 早期增长 | 基础 + 收入关联 | 12 小时 | 2 周内获得洞察 |
| 增长阶段 | 完整归因系统 | 40 小时 | 1 个月开始优化 |
| 规模化阶段 | 多触点建模 | 80+ 小时 | 2 个月获得高级洞察 |
第一阶段:基础追踪(第 1 周)
- 在落地页添加 UTM 参数解析
- 在数据库中存储触点
- 在用户注册时追踪首次/末次来源
- 构建基础归因仪表板
预计工作量: 独立开发者 8-12 小时
第二阶段:收入关联(第 2-3 周)
- 将触点关联到订阅事件
- 实现首次点击 vs 末次点击归因
- 添加渠道表现指标
- 创建按渠道查看 MRR 的仪表板
预计工作量: 16-24 小时
第三阶段:高级功能(第 2 个月)
- 多触点归因建模
- 内容级别追踪(utm_content)
- 营销活动队列分析
- 渠道表现自动化提醒
预计工作量: 40+ 小时
工具和集成
与 UTM 配合良好的分析平台:
- PostHog — 开源自托管,UTM 支持优秀
- Mixpanel — 内置高级归因建模
- Amplitude — 开箱即支持收入归因
- GA4 — 免费但归因功能有限
数据库选型建议:
- PostgreSQL/MySQL — 标准 SQL 非常适合归因查询
- SQLite (D1) — 适合小型应用,复杂分析能力有限
- ClickHouse — 对大多数 SaaS 来说性能过剩,但大数据场景表现出色
常见问题
我应该使用哪种归因模型?
从末次点击开始 因为最简单。它能告诉你转化前最后触发了哪个接触点。有了 6 个月以上的数据后,可以尝试首次点击和多触点模型。
如何区分自然社交和付费社交?
使用不同的 utm_medium 值:
- 付费社交:
utm_medium=cpc或utm_medium=paid_social - 自然社交:
utm_medium=social
这样可以区分"我们的推文病毒式传播了"和"我们花了 500 美元得到了这些结果"。
我应该追踪内部链接吗?
不要。不要在内部导航上添加 UTM 参数。这会污染数据。用分析平台的页面追踪单独追踪内部导航。
引荐流量怎么处理?
处理 referrer header:
const referrer = request.headers.get('referer')
if (referrer && !utm.source) {
const domain = new URL(referrer).hostname
source = domain.replace('www.', '')
medium = 'referral'
}
归因窗口应该设多长?
B2B SaaS 标准是 30 天。 考虑周期更长的消费类应用(房地产、企业软件)使用 90 天。关键是保持一致性——分析中途不要改变窗口设置。
如果一个用户有多个触点怎么办?
将所有触点存储在归因表中。 你的仪表板应该展示:
- 首次触点 — 他们是如何发现你的
- 末次触点 — 转化前最后触发了什么
- 触点数量 — 转化前经历了多少次互动
有 5 个以上触点的用户比单一触点用户的转化率明显更高。
下一步
从基础开始:在落地页添加 UTM 参数解析,在数据库中存储触点,在用户注册时追踪首次/末次来源。随着时间推移,你可以逐步建立完整的归因模型。
想要查看包含数据库 Schema、中间件和管理仪表板的完整实现,请参阅 TanStack Ship 的 UTM 归因模块,整个系统开箱即用。
推荐阅读
- SaaS 增长框架 — 完整的增长运营技术栈
- 营销心理学 — 理解驱动转化的核心因素
- PostHog 搭建指南 — 分析实现最佳实践
追踪每一次点击,归因每一笔收入。 从猜测到确定之间,差的只是一套完善实施的 UTM 系统。