UTM 归因完整指南:从点击到收入确认

追踪每个营销触点,从首次点击到成交闭环。本指南涵盖 UTM 参数规范、数据库 Schema 设计、归因模型和收入归因仪表板的完整搭建过程。

Huifer
Huifer
July 27, 202612 min read
其他语言:Deutsch · English

本文由 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 实现

typescript
// 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 在用户注册前追踪匿名访客:

typescript
// 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 数据:

typescript
// 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 或中间件中调用:

typescript
// 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:

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 天:

typescript
// 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;
}

搭建归因仪表板

这是你真正想看到的内容:

渠道表现表格

typescript
// 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)通常代表品牌认知。应该单独追踪:

typescript
// 如果没有 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:

typescript
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 归因模块,整个系统开箱即用。


推荐阅读


追踪每一次点击,归因每一笔收入。 从猜测到确定之间,差的只是一套完善实施的 UTM 系统。