「問い合わせフォーム、開いた人のうち何割が実際に送信まで辿り着いてるんだろう」。GA4を見れば一発で分かる、そう思っていた時期が自分にもありました。
GA4を入れてイベントも一通り送っているはずなのに、いざこの質問に答えようとすると詰まった。contact_submit は送信成功のたびに飛んでいる。でも、それが「フォームを開いた人のうち何%か」を出そうとすると、フォーム到達のイベントがそもそも無い。分母がない状態で分子だけ数えていたことになる(それ、割合じゃなくてただの件数では)。
同じタイミングで、もう一つ答えられない質問があった。「ブログ記事を読んだ人のうち、どれくらいがソリューションページ経由で問い合わせまで進んでいるか」。ブログのCTAは押されている(cta_clickは飛んでいる)。ソリューションページも見られている(solution_viewも飛んでいる)。なのに、この2つのイベントを「同じ人の同じ旅」として繋げる方法がなかった。
この記事では、①フォームの離脱段を可視化するファネル計測、②ブログ→ソリューションページ→問い合わせの送客率を追う仕組み、③その過程で踏んだ「SPA遷移だとreferrerが更新されない」という具体的な罠、の3つを実装レベルで解説する。
この記事で学べること
- GA4のイベントに
funnel_stepのような段差情報を持たせて、離脱ポイントを探索で可視化する方法 - ページを跨いだ送客率(A→B→Cの経路)を、単発のイベントの集合から計測可能にする設計
next/linkのようなクライアントサイドルーティングでdocument.referrerが更新されない問題と、sessionStorageを使った回避策- 1つのCTAコンポーネントに複数の役割(情報提供 / コンバージョン)を持たせるとき、計測でそれを分離する方法
前提条件
- GA4に
gtag()(または相当のイベント送信関数)でカスタムイベントを送る仕組みが既にある - Next.jsやReact Routerなど、クライアントサイドルーティングを使うSPA構成のサイトである
- 複数ページを跨ぐユーザー行動(記事ページ→紹介ページ→フォーム、など)を1つの経路として評価したい
実装1:フォームの離脱段をfunnel_stepで可視化する
問い合わせフォームには元々contact_submit(送信成功)のイベントしかなかった。これだと「送信された数」は分かるが、「フォームまで来たのに離脱した数」が見えない。分母を作るために、フォームのライフサイクルに合わせて3つのイベントを新設し、それぞれにfunnel_nameとfunnel_stepという共通のメタデータを付けた。
| step | イベント | 意味 |
|---|---|---|
| 1 | contact_view | フォームが画面にマウントされた(到達) |
| 2 | contact_start | 最初の入力欄にフォーカスが当たった(入力開始) |
| 3 | contact_submit | 送信が成功した |
export const CONTACT_FUNNEL_NAME = 'contact' as const;
export const CONTACT_FUNNEL_STEP = {
view: 1,
start: 2,
submit: 3,
} as const;
export function trackContactView(params: ContactViewParams) {
gtag('event', 'contact_view', {
...params,
funnel_name: CONTACT_FUNNEL_NAME,
funnel_step: CONTACT_FUNNEL_STEP.view,
});
}
funnel_nameとfunnel_stepを共通の型で持たせておくと、GA4の探索機能(ファネルデータ探索)でstep順に並べるだけで、view→start→submitのどこで人数が落ちているかが一目で分かる。逆に言えば、この2つのフィールドを付けなければ、GA4側でイベントを「順序のある1つの旅」として扱ってもらえない。単なるバラバラのカウントのままだ。
一方で、エラー系のイベント(contact_error)は意図的にfunnel_stepを持たせなかった。バリデーションエラーやネットワークエラーは「view→start→submitのどこかで起きる枝」であって、それ自体が新しい段ではないからだ。段差を表すものと、離脱理由を表すものは、同じfunnel_nameを共有しつつも役割が違う。
export function trackContactError(params: ContactErrorParams) {
// contact_error は段差ではなく離脱原因の枝。funnel_name のみ付与して
// funnel_step は持たせない(段を1つ増やすとfunnelの意味が壊れる)
gtag('event', 'contact_error', {
...params,
funnel_name: CONTACT_FUNNEL_NAME,
});
}
エラーの原因もreason: 'validation' | 'network' | 'server'のように分類しておくと、「フォームの作りが悪くて離脱している」のか「そもそもネットワークが不安定な読者が多い」のかを後から切り分けられる。ここを一緒くたにerrorイベント1つで済ませていたら、この区別自体ができなかった。
実装2:ブログ→ソリューションページ→問い合わせの送客率を計測する
もう一つの課題が、ページを跨いだ送客率だった。cta_click(ブログ記事内のCTAクリック)とsolution_view(ソリューションページの閲覧)は元々別々に送られていて、この2つを繋ぐ共通のキーがなかった。
解決策はシンプルで、「送客元が記事ページである場合、そのパスをパラメータとして持ち回る」ことにした。
export interface CTAClickParams {
cta_type: 'hero' | 'section' | /* ... */ 'blog_inline_cta' | /* ... */;
source_page: string;
destination: string;
/**
* 送客元が blog 記事の場合、その記事パス(例: /blog/foo)。
* solution ページ等の CTA で getReferrerBlog() の値を渡すと、
* blog→solution→contact の送客率を GA4 で算出できる
*/
referrer_blog?: string;
}
export interface SolutionViewParams {
solution_type: string;
referrer_blog?: string;
}
referrer_blogをsolution_view側にもcta_click側にも持たせておくことで、送客率は次のシンプルな割り算で出せるようになる。
送客率 = cta_click[referrer_blog] の件数 / solution_view[referrer_blog] の件数
ここまでは順調だった。実装した直後、動作確認のためにブラウザで実際にブログ記事からリンクを踏んでソリューションページに飛んでみたところ、referrer_blogが毎回undefinedになっている。コードにバグは見当たらないのに、値だけが取れない。
罠:SPA遷移だとdocument.referrerが更新されない
原因を辿ると、referrer_blogの取得元がdocument.referrerだったことに行き着いた。フルページリロードを伴う遷移ならdocument.referrerにはちゃんと直前のURLが入る。ところがnext/linkのようなクライアントサイドルーティングでは、ブラウザ的には「ページ遷移していない」扱いになるため、document.referrerが更新されない。画面上はページが切り替わっているのに、ブラウザの内部では「まだ同じページにいる」ことになっているわけです。このズレ、SPAを使い始めた誰もが一度は踏むんじゃないだろうか(少なくとも自分は実装してからブラウザで踏んで気づいた口だ)。
コード自体は「Before」の実装として何もおかしくない。単に、SPAというページ遷移の性質そのものがdocument.referrerという古いブラウザAPIの前提から外れているだけだ。これは特定のブラウザのバグでも実装ミスでもなく、SPAとリファラー計測の間に元々ある構造的なズレになる。
対処として、CTAをクリックした時点(=まだdocument.referrerを気にしなくていい、確実に自分のページにいるタイミング)で、遷移元のパスを自分でsessionStorageに保存しておくことにした。
const BLOG_ORIGIN_SESSION_KEY = 'ppk_blog_origin';
// 記事ページのパスを sessionStorage に保存する。
// CTA クリック時に呼び出すことで、SPA 遷移後のページでも
// blog 起点を正確に計測できるようにする。
export function setBlogOriginInSession(blogPath: string): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(BLOG_ORIGIN_SESSION_KEY, blogPath);
} catch {
// Safari プライベートブラウズなど sessionStorage が使えない環境では無視
}
}
// blog 起点パスを返す。次の順で参照する:
// 1. sessionStorage(SPA 遷移での document.referrer 欠落を補う)
// 2. document.referrer(フルページリロード / 外部遷移の場合)
export function getReferrerBlog(): string | undefined {
const sessionOrigin = getBlogOriginFromSession();
if (sessionOrigin) return sessionOrigin;
if (typeof window === 'undefined') return undefined;
const referrer = document.referrer;
if (!referrer) return undefined;
try {
const url = new URL(referrer);
if (url.hostname !== window.location.hostname) return undefined;
const match = url.pathname.match(/^\/blog\/(.+)/);
return match ? url.pathname : undefined;
} catch {
return undefined;
}
}
sessionStorageを優先し、無ければdocument.referrerにフォールバックする、という順序がポイントだ。SPA遷移では前者が効き、フルリロードや外部サイトからの流入では後者がそのまま機能する。片方だけに寄せると、どちらかの遷移パターンで計測が抜け落ちる。
| 遷移方法 | document.referrer | sessionStorage併用 |
|---|---|---|
| フルページリロード / 外部リンク | 更新される | 不要(フォールバックで機能) |
| クライアントサイドルーティング(SPA) | 更新されない | 必須(これが無いと計測が欠落する) |
実装3:1つのCTAに2つの役割があるなら、計測でも分離する
送客率を計測できるようになった段階で、もう一つ気づいたことがある。記事末尾のCTAコンポーネントには、実は性質の違う2種類のリンクが同居していた。
- 記事の内容についてもっと詳しく知りたい読者向けの「詳細ページへの情報リンク」
- 導入を検討していて、無料相談やヒアリングに進みたい読者向けの「コンバージョンリンク」
この2つを同じcta_clickイベントで一緒くたに計測していると、「CTAのクリック数が増えた」という数字だけでは、それが情報収集目的のクリックなのか、商談化に繋がる意欲の高いクリックなのかを見分けられない。そこでクリック時にab_variantのようなラベルを分けて送るようにした。
const handleClick = (destination: string, abVariant: string) => {
const sourcePage = window.location.pathname;
// 情報リンク側のクリック時に、遷移元のパスを保存しておく
if (abVariant === 'dev_info') {
setBlogOriginInSession(sourcePage);
}
trackCTAClick({
cta_type: 'blog_inline_cta',
source_page: sourcePage,
destination,
ab_variant: abVariant,
});
};
こうしておくと、「情報リンク(dev_info)はソリューションページへの送客として評価する」「コンバージョンリンク(enterprise_cv)は問い合わせ・ヒアリング予約への転換として評価する」という具合に、同じコンポーネントの中でも指標を分けて追える。1つのボタンに複数の意図を詰め込んだままだと、改善のためにABテストをしても何が効いたのか特定できない(クリックされた事実は分かっても、なぜクリックされたのかは永遠に藪の中だ)。役割ごとにラベルを分けておくのは、後から効果測定をする自分への先行投資に近い。
動作確認
自分のSPAサイトでも、まずSPA遷移がreferrerを消す挙動そのものを再現できる。ブラウザのDevToolsを開いた状態で、次の手順を試してみてほしい。
- サイト内の2ページを、クライアントサイドルーティングのリンク(
<Link>等)で繋いでおく - 遷移先ページのマウント時に、一時的に次のログを仕込む
console.log('document.referrer:', document.referrer)
- サイト内リンクをクリックして遷移し、コンソールを確認する → SPA遷移直後は空文字になっているはず
- 同じ遷移先ページを直接URLで開き(フルリロード)、再度確認する → 直前ページのURLが入っているはず
sessionStorageによるフォールバックそのものは、ブラウザ環境をモックしたテストで再現・検証できる。以下はVitest + jsdom想定の最小例で、自分のプロジェクトの計測関数に合わせて書き換えれば動くはずだ。
import { describe, expect, it } from 'vitest';
describe('SPA遷移でのreferrer計測', () => {
it('sessionStorageの値をdocument.referrerより優先する', async () => {
const { setBlogOriginInSession, getReferrerBlog } = await import(
'./your-analytics-module'
);
// SPA遷移をシミュレーション: document.referrer は空のまま
(globalThis as any).document = { referrer: '' };
setBlogOriginInSession('/blog/some-article');
expect(getReferrerBlog()).toBe('/blog/some-article');
});
it('sessionStorageが空ならdocument.referrerにフォールバックする', async () => {
const { getReferrerBlog } = await import('./your-analytics-module');
(globalThis as any).document = {
referrer: 'https://example.com/blog/referrer-article',
};
expect(getReferrerBlog()).toBe('/blog/referrer-article');
});
});
注意点・Tips
- funnel_stepは「段差」だけに使う: エラー系イベントのように「どこかの段で起きる枝」まで段番号を振ると、GA4のファネル探索が本来のview→start→submitという直線的な意味を失う。段差を表すイベントと、離脱理由を説明するイベントは分けて設計する。
- sessionStorageは書き込み失敗を握りつぶす: Safariのプライベートブラウズなど、
sessionStorageが使えない環境は普通に存在する。ここで例外を投げるとページ全体が壊れかねないので(計測のためにサイトが真っ白になったら、それこそ本末転倒だ)、try/catchで握りつぶしてdocument.referrerのフォールバックに任せる設計にしている。 - 効果測定は計測を入れてからが本番: ここまでの実装は「計測できる箱を用意した」段階であって、それだけで送客率やCVRが改善するわけではない。実際にどれだけ送客できているかは、計測を入れてから一定期間データを溜めてはじめて評価できる話であり、この記事の実装単体で成果を主張するものではない。
まとめ
問い合わせの離脱段が見えない問題も、ブログからの送客率が追えない問題も、根っこは同じで「イベントは飛んでいるのに、それらを1つの旅として繋げる共通のキーがなかった」ことだった。funnel_stepで段差を、referrer_blogで経路を、それぞれ共通のパラメータとして持たせるだけで、バラバラのイベントが1つのファネルとして読めるようになる。
そしてSPAで計測を組む場合は、document.referrerのような古いブラウザAPIが「フルページ遷移」を前提にしていることを常に疑ったほうがいい。同じ問題はdocument.referrerに限らず、URLの変化を検知しない前提で書かれたAPIやライブラリ全般で起こりうる。あなたの実装でクライアントサイドルーティングを使っているなら、一度「このイベントは遷移直後に正しい値を持っているか」を、フルリロード時とSPA遷移時の両方で見比べてみる価値がある。



