6本のアプリを個人開発で運用していると、毎月いちばん大きな塊として目に入るのが「インストール済みなのに30日以上開いていない」休眠ユーザーの存在です。プッシュ通知を送ろうにも、通知許可をオフにした層には届きません。
その層に唯一、こちらから接点を持てる場所が App Store のプロダクトページでした。検索やランキングからアプリを再訪したとき、製品ページ上に小さなイベントカードを出せる仕組み — それがアプリ内イベント(In-App Events)です。
私自身、ある壁紙アプリで季節の新作配信をイベント化したところ、製品ページからの再訪導線が一本増えました。ここでは Rork(Expo)で生成したアプリにアプリ内イベントを組み込み、休眠ユーザーをアプリ内の目的地まで連れ戻すまでの実装を共有します。
アプリ内イベントが「広告以外の再訪導線」になる理由
アプリ内イベントは、App Store の製品ページや検索結果、おすすめタブにイベントカードを掲出できる Apple 公式の仕組みです。広告費をかけずに、すでにアプリを知っているユーザーへ「今これが起きています」と伝えられます。
ポイントは、カードをタップしたユーザーがアプリの該当画面へ直接着地する点です。単なる起動ではなく、イベントの中身(新シーズン、ライブ配信、期間限定チャレンジなど)に直結した画面へ送り込めます。この一直線の導線が、休眠ユーザーの「開いたけれど何をすればいいか分からず閉じる」を減らしてくれます。
App Store Connect では、1つのアプリにつき承認済みのイベントを同時に最大10件まで保持できます。私の運用では「常設の月替わりイベント1枠+スポットの期間限定1枠」の2枠を基本にして、残りの枠は予約投入のバッファとして空けています。
まず種別とターゲットを決める
実装に入る前に、イベントの設計を固めておきます。ここを曖昧にすると、後のカード審査で差し戻されやすくなります。
Apple はイベントの目的として、次の7種別を用意しています。
チャレンジ(一定期間で達成を目指す)
競争(ユーザー同士で競う)
ライブイベント(決まった時刻に行われる)
メジャーアップデート(大型の機能追加)
新シーズン(定期更新コンテンツの新章)
プレミア(新規コンテンツの初公開)
スペシャルイベント(上記に当てはまらない特別企画)
個人開発の癒し系・壁紙系アプリであれば、「新シーズン」か「プレミア」が自然に当てはまります。チャレンジや競争はソーシャル要素のあるアプリ向けで、無理に当てはめると審査側に意図が伝わりにくくなります。
ターゲットは3つのセグメントから選べます。新規ユーザー、インストール済みだが最近開いていない休眠ユーザー、そして現在のアクティブユーザーです。全員へ一斉配信もできますが、私は休眠ユーザー向けと新規ユーザー向けでイベントカードの文言を分けています。休眠層には「戻ってくる理由」を、新規層には「今始める理由」を書く、という整理です。
イベント名は30文字、短い説明は50文字という厳しい上限があります。ここは推敲に時間をかける価値があります。「6月の新作壁紙が公開中」のように、開いた先で何が待っているかを具体的に書くのが効きます。
ディープリンクの着地先を expo-router で受ける
アプリ内イベントには、カードごとにディープリンクの設定が必須です。Apple はセキュリティの観点からユニバーサルリンク(Universal Links)を推奨しています。URL 短縮サービスや余計なリダイレクトを挟むと、アプリが URL を解釈できずにトップ画面へ落ちてしまうため避けます。
Rork(Expo)アプリでユニバーサルリンクを受けるには、まず app.json にドメインの関連付けを設定します。
{
"expo" : {
"scheme" : "mywallpaper" ,
"ios" : {
"associatedDomains" : [ "applinks:events.example.com" ]
}
}
}
次に、ドメイン側に Apple App Site Association ファイルを配置します。これがないとリンクをタップしてもブラウザが開くだけで、アプリには渡りません。
{
"applinks" : {
"details" : [
{
"appID" : "TEAMID.com.example.mywallpaper" ,
"paths" : [ "/event/*" ]
}
]
}
}
このファイルは https://events.example.com/.well-known/apple-app-site-association に、Content-Type: application/json で、拡張子なしで配信する必要があります。私は最初ここで一度つまずきました。Cloudflare のルールで拡張子なしのパスに JSON の Content-Type が付かず、リンクが沈黙して起動しか起きない、という症状です。配信ヘッダーは必ず実機で確認してください。
着地の受け口は expo-router で組みます。/event/[id] というルートを切り、イベント ID に応じて目的の画面状態を組み立てます。
// app/event/[id].tsx
import { useLocalSearchParams, useRouter } from "expo-router" ;
import { useEffect } from "react" ;
import { logEvent } from "../../lib/analytics" ;
const EVENT_DESTINATIONS : Record < string , string > = {
"june-new-wallpapers" : "/collection/2026-06" ,
"summer-premiere" : "/collection/summer" ,
};
export default function InAppEventEntry () {
const { id } = useLocalSearchParams <{ id : string }>();
const router = useRouter ();
useEffect (() => {
if ( ! id) return ;
// どのイベントカードから来たかを必ず記録する
logEvent ( "iae_open" , { event_id: id });
const destination = EVENT_DESTINATIONS [id] ?? "/" ;
// 履歴に /event/[id] を残さず、目的地へ置き換える
router. replace (destination);
}, [id]);
return null ;
}
ここで router.push ではなく router.replace を使うのが要点です。push にすると戻るボタンで /event/[id] という中間画面に戻ってしまい、ユーザーが混乱します。イベントの着地先は「経由地」であって「滞在地」ではない、という設計にしておきます。
起動経路を取りこぼさない初期化
ユニバーサルリンクには、アプリが起動していない状態でのコールドスタートと、バックグラウンドから復帰するウォームスタートの2経路があります。expo-router は前面化後のリンクは自動でルーティングしますが、コールドスタート時の初期 URL は明示的に拾っておくと安全です。
// lib/useInitialEventLink.ts
import * as Linking from "expo-linking" ;
import { useEffect } from "react" ;
import { useRouter } from "expo-router" ;
export function useInitialEventLink () {
const router = useRouter ();
useEffect (() => {
let handled = false ;
Linking. getInitialURL (). then (( url ) => {
if ( ! url || handled) return ;
const { path } = Linking. parse (url);
if (path?. startsWith ( "event/" )) {
handled = true ;
router. replace ( "/" + path);
}
});
}, []);
}
実機でのテストは、メモ App などに https://events.example.com/event/june-new-wallpapers を貼り付け、タップして確かめます。シミュレータではユニバーサルリンクの挙動が安定しないため、必ず実機で、かつアプリを完全終了した状態とバックグラウンド状態の両方を試してください。
インプレッションから復帰までを3段で計測する
イベントを出して終わりにしないために、計測の設計を最初に決めておきます。アプリ内イベントの効果は、次の3段のファネルで見ると判断しやすくなります。
インプレッション数 — カードが表示された回数(App Store Connect の分析で取得)
開封数 — カードをタップしてアプリが起動した回数(iae_open イベント)
復帰アクション数 — 着地先で意図した行動(壁紙の保存、コレクション閲覧など)に至った回数
1段目は App Store Connect の「分析」内、アプリ内イベントのインプレッションとして提供されます。2段目以降は自前のアナリティクスで埋めます。私は着地先画面に専用のタグを仕込み、イベント経由のセッションを区別しています。
// lib/analytics.ts
let activeEventId : string | null = null ;
export function logEvent ( name : string , params : Record < string , unknown > = {}) {
// 実際の送信先は Firebase Analytics 等に差し替える
if (name === "iae_open" && typeof params.event_id === "string" ) {
activeEventId = params.event_id;
}
send (name, { ... params, via_event: activeEventId });
}
export function clearEventAttribution () {
activeEventId = null ;
}
via_event を全イベントに添えることで、「イベント経由で来たユーザーが、その後どの画面まで進んだか」を後から追えます。私の運用では、開封のうち復帰アクションまで到達した割合を主要指標に置き、ここが伸びないイベントは文言かカード画像のどちらかを翌月に差し替えています。これは AdMob の eCPM のような収益指標とは別軸の、リテンション側の数字です。新規セグメント向けには、着地後すぐに初回のオンボーディングを挟むかどうかでも復帰率が変わるため、私はイベント経由のユーザーには通常のオンボーディングを省く運用を推奨しています。
App Store Connect 上の数値とアプリ内の数値は、計測のタイミングがずれるため完全には一致しません。インプレッションは Apple 側、開封以降は自前側、と割り切って、それぞれの伸びを別々に見るほうが運用が回ります。
審査と公開タイミングの落とし穴
アプリ内イベントのカードは、アプリ本体とは別に App Review を通ります。ここで見落としやすいのが、リードタイムです。公開したい日のギリギリにイベントを提出すると、審査が間に合わずカードが出ないまま開始日を迎えてしまいます。私は開始日の最低5営業日前には提出を済ませる運用にしています。
差し戻しで多いのは、カードの文言と着地先の中身が一致していないケースです。「新作公開中」と書いたのに、着地先が一般的なホーム画面のままだと、審査側は約束と実体の不一致と判断します。ディープリンクの着地先を、イベントの内容そのものに合わせて作り込んでおくことが、審査通過と効果の両方に効きます。
もう一つ、イベントには開始・終了の期間を設定しますが、終了後にディープリンクが死んだ画面へ着地しないよう、EVENT_DESTINATIONS に存在しない ID はトップへフォールバックさせています。期間限定コンテンツを消した後に古いリンクが踏まれても、空白画面を見せないための回避策です。終了直後の差し戻しやクラッシュにつながりやすい箇所なので、本番運用では必ずフォールバックを用意してください。
次の一歩
まずは現在運用中のアプリで「新シーズン」か「プレミア」のイベントを1枠だけ用意し、休眠ユーザー向けセグメントで小さく出してみてください。ユニバーサルリンクの着地先を1画面きちんと作り、開封から復帰アクションまでの割合を1ヶ月測れば、次に何を差し替えるべきかが数字で見えてきます。
同じように休眠ユーザーの呼び戻しに悩んでいる個人開発の方の、最初の一歩になれば幸いです。