新しい SaaS のウェイトリスト

新しい SaaS のウェイトリスト3 分で読めます

SvelteKit でウェイトリストを作る

SvelteKit 3 のウェイトリスト。フックが最初の ?ref= を Cookie に保存し、名前付きのフォーム Action が登録を転送し、再訪者には登録済みと表示します。

Preet SutharIkiform 創業者

訪問者の最初のリクエストで hooks.server.ts が ?ref= を読んで Cookie に保存するので、サイト内を回った後でも、連れてきたリンクの成果として登録を記録できます。名前付きのフォーム Action が Cookie を読み、シークレットキーで登録をフォームバックエンドに転送し、2 つ目の Cookie を設定するので、再訪した人には再び登録を求めず挨拶を表示できます。

メインガイドの SvelteKit のタブは、デフォルトの Action と use:enhance を使い、ref は homepage に固定しています。このページでは、実際のローンチに必要なものを足します。訪問者はニュースレターからブログ記事に来て、しばらく読み、2 ページ先で登録します。その登録は、メールアドレスを入力したページではなく、ニュースレターの成果として記録したいはずです。

SvelteKit ならではの違い

SvelteKit の 2 つの機能がこれにうまく合い、1 つのルールが構成を決めます:

機能ここでの役割
hooks.server.ts の handleサーバーレンダリングするすべてのリクエストの前に実行されるので、訪問者がどのページに着いても ?ref= を見られる
event.cookies最初の流入元を 30 日間保存する。Action が読み戻すので、改ざんされうる hidden の入力欄が要らない
use:enhance 付きのフォーム Action1 つのサーバーコードで、JavaScript なしでも送信でき、読み込まれれば機能を上乗せする
ルールAction のあるページはプリレンダーできない。その POST にサーバーが応答する必要があるため

hidden の入力欄ではこれはできません。フォームのあるページしかわからず、URL のタグは最初のクリックで消えてしまいます。サーバーで設定した Cookie ならすべてのページで保持され、訪問者が見たり編集したりすることもありません。

これはファーストタッチのアトリビューションで、最初にタグ付きで来たリンクが勝ちます。後で別のリンクから来ても上書きしません。最後のリンクを評価したいなら、!event.cookies.get(...) のチェックを外してください。

SvelteKit で訪問者の最初の ?ref= が登録まで付いていく初日登録/blog/launch?ref=news最初の訪問handle フックwaitlist_ref=news を設定/pricing の次に /クエリ文字列なし?/join の Actionwaitlist_ref を読むIkiformref = newswaitlist_joined次の訪問では登録済みと表示リクエスト閲覧JSONCookieSvelteKit で訪問者の最初の ?ref= が登録まで付いていく初日登録/blog/launch?ref=news最初の訪問handle フックwaitlist_ref=news を設定/pricing の次に /クエリ文字列なし?/join の Actionwaitlist_ref を読むIkiformref = newswaitlist_joined次の訪問では登録済みと表示リクエスト閲覧JSONCookie
  1. 訪問者が ?ref=news 付きのリンクからブログ記事に来ます。
  2. handle フックがタグを見つけ、waitlist_ref の Cookie がまだないので 30 日間の Cookie を設定します。
  3. 料金ページを読み、ホームページに戻ります。どちらの URL にもタグはありませんが、Cookie には残っています。
  4. 登録します。join の Action が waitlist_ref を読み、ref を news にして登録を Ikiform に転送します。
  5. 成功すると Action が waitlist_joined を設定するので、次の訪問ではホームページにフォームではなくお礼の一文が表示されます。
流入元はフォームではなく httpOnly の Cookie にあるので、ページを移動しても残り、ページ上で編集されることもありません。

プロジェクトを準備する

SvelteKit のドキュメントガイドのとおりにフォームとシークレットキーを作成します。このページは SvelteKit 3 を使います。ファイル:

プロジェクト
src/
  env.ts
  hooks.server.ts
  routes/
    +page.server.ts
    +page.svelte
    blog/[slug]/+page.svelte
vite.config.ts
.env

シークレットを宣言する

SvelteKit 3 では $env/... モジュールが非推奨になり、宣言した変数を使うようになりました。キーは src/env.ts で宣言します。変数は public と指定しない限り非公開で、$app/env/private はブラウザーのコードにインポートできません。

src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
サーバー側のバージョンはシークレットキーを付けて送信するので、Ikiform は Allowed domains とその人間確認をスキップします。その場合、すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計に適用されてしまいます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

Action は email と ref を送ります。フォームの Fields タブでフィールドを定義している場合は、両方を含めてください:

フィールド
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}

作ってみる

流入元を記録する

src/hooks.server.ts
import type { Handle } from "@sveltejs/kit/hooks";

const SOURCE = /^[a-z0-9_-]{1,40}$/;
const THIRTY_DAYS = 60 * 60 * 24 * 30;

export const handle: Handle = async ({ event, resolve }) => {
  const query = event.url.searchParams;
  const tag = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase();

  if (SOURCE.test(tag) && !event.cookies.get("waitlist_ref")) {
    event.cookies.set("waitlist_ref", tag, { maxAge: THIRTY_DAYS, httpOnly: true, sameSite: "lax" });
  }
  return resolve(event);
};
  • タグは小文字にし、Ikiform の max_length が許すのと同じパターンに一致しなければなりません。細工されたリンクでゴミが保存されることはありません。
  • httpOnly で Cookie をページのスクリプトから遠ざけます。sameSite: "lax" でも、フォーム自身の POST には Cookie が送られます。
  • SvelteKit 3 では Cookie の path のデフォルトが / なので、指定しなくてもすべてのルートに届きます。

名前付きの Action

src/routes/+page.server.ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions, PageServerLoad } from "./$types";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const YEAR = 60 * 60 * 24 * 365;

export const load: PageServerLoad = ({ cookies }) => ({
  joined: cookies.get("waitlist_joined") === "yes",
});

export const actions = {
  join: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    if (!EMAIL.test(email)) return fail(400, { email, message: "Enter the email we should send your invite to." });

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({
        email,
        ref: cookies.get("waitlist_ref") ?? "direct",
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    }).catch(() => null);

    if (!response) return fail(504, { email, message: "We couldn't reach the list. Try again." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(response.status === 429 ? 429 : 400, {
        email,
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    }

    cookies.set("waitlist_joined", "yes", { maxAge: YEAR, httpOnly: true, sameSite: "lax" });
    return { joined: true };
  },
} satisfies Actions;
  • 名前付きの Action join にしておけば、後でこのページに他の Action を足す余地が残ります。SvelteKit では、名前付きの Action とデフォルトの Action を並べられません。
  • ブラウザーの Origin ヘッダーを引き継いで渡します。Ikiform は無料の localhost テスト送信をこのヘッダーで判定しますが、Action 自身の fetch はこれを送らないからです。
  • fail() は入力されたメールアドレスとメッセージを返します。SvelteKit 3 では、enhance された送信は fail() に渡したステータスを使うので、page.status は 400 や 429 になります。
  • load が waitlist_joined を読むので、再訪者には登録済みであることが表示されます。これは 1 つのブラウザー向けの気遣いで、重複排除ではありません。別のデバイスからの 2 回目の登録も Ikiform は保存します。

ページ

src/routes/+page.svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { data, form }: PageProps = $props();
  let pending = $state(false);
</script>

<h1>Acme is almost here</h1>

{#if data.joined || form?.joined}
  <p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
  <form
    method="POST"
    action="?/join"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ reset: false });
        pending = false;
      };
    }}
  >
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message}
      <p role="alert">{form.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
  </form>
{/if}
  • action="?/join" で Action を指定します。JavaScript がなければブラウザーはそこに送信し、SvelteKit は form を設定してページを描画します。
  • use:enhance は、ページのハイドレーション後に fetch で送信します。update({ reset: false }) はエラーの後も入力欄にメールアドレスを残すので、訪問者は打ち直さずにタイプミスだけを直せます。
  • pending で Action が応答するまでボタンを無効にし、遅いスマートフォンでの二重送信を防ぎます。
  • data.joined は load から、form.joined は Action から来ます。両方を確認すれば、1 つの条件で再訪者と新しい登録の両方に対応できます。

ハニーポットはそのまま Ikiform に届くので、埋めたボットはスパムとして保存されます。シークレットキー付きの送信は Ikiform の人間確認をスキップするので、ここで Spam check をオンにしても意味はありません。

プリレンダーとデプロイ

ホームページには Action があるので、サーバーでレンダリングする必要があります。ブログ記事などのコンテンツは export const prerender = true でプリレンダーできますが、訪問者がプリレンダーされたファイルを開いても handle は実行されません。実行されたのはビルド時の 1 回だけです。プリレンダーされたページへ直接来たタグ付きリンクでは Cookie は設定されません。キャンペーンのリンクは、ホームページなどサーバーレンダリングされるページに向けてください。

ホストアダプター注意点
Vercel@sveltejs/adapter-vercelSvelteKit 3 ではここで edge ランタイムがなくなったので、Action は Node の関数で動きます
Netlify@sveltejs/adapter-netlifyキーは Environment variables で設定します
Cloudflare@sveltejs/adapter-cloudflareアダプターのピア依存である wrangler 4.118 以降が必要です。キーは wrangler secret put IKIFORM_SECRET_KEY で設定します

動作を確認する

  1. vite dev を実行し、http://localhost:5173/?ref=test を開いてから、クエリを外して再読み込みします。ブラウザーの開発者ツールで見ると、waitlist_ref の Cookie は test のままです。
  2. 登録します。再読み込みなしでフォームがお礼の一文に置き換わり、Submissions の登録の ref は test になっています。
  3. ページを再読み込みします。フォームではなくお礼の一文が表示されます。
  4. Cookie を削除し、JavaScript をオフにして a@b を送信します。エラー付きでページが戻り、アドレスは入力欄に残っています。
  5. ハニーポットに値を入れて _gotcha=bot で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
  6. vite dev からの送信は localhost のオリジンを持つので、テスト送信として数えられます。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースは localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。

ハマりどころ

問題対処法
Action のあるページはプリレンダーできないというエラーでビルドが失敗するレイアウトがすべてのルートに prerender = true を設定しています。ウェイトリストのページの +page.server.ts で export const prerender = false を設定してください。
ref が常に direct になるタグ付きのリンクがプリレンダーされたページを指していたため handle が見ていないか、以前の訪問ですでに Cookie が設定されています。開発者ツールで Cookie を確認してください。
アップグレード後に $env/static/private の警告が出るSvelteKit 3 では $env モジュールが非推奨になりました。キーを src/env.ts で宣言し、$app/env/private からインポートしてください。
unknown_field 付きの 422フィールド定義に ref がありません。上の定義のように追加してください。
402 plan_requiredワークスペースにプランがなく、送信が本番のサイトから来ています。送信データは失われず、保留されています。Settings › Plan and usage でプランを選ぶかパックを購入すると、保留中の送信データが届きます。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com