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

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

公開日 2026-10-25 · https://ikiform.com/ja/guides/waitlist/sveltekit

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

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

## SvelteKit ならではの違い

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

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

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

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

図: SvelteKit で訪問者の最初の ?ref= が登録まで付いていく

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 のドキュメントガイド](https://ikiform.com/ja/docs/guides/sveltekit.md)のとおりにフォームとシークレットキーを作成します。このページは SvelteKit 3 を使います。ファイル:

`プロジェクト`:

```text
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`:

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

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> サーバー側のバージョンはシークレットキーを付けて送信するので、Ikiform は **Allowed domains** とその人間確認をスキップします。その場合、すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計に適用されてしまいます。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

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

`フィールド`:

```json
{
  "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`:

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

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

```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-vercel` | SvelteKit 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 のパックを購入してください。パックのクレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| 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** でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |

## よくある質問

### SvelteKit のフォームで UTM の流入元を記録するには？

最初のリクエストで handle フックがクエリを読み、タグを httpOnly の Cookie に保存し、フォームの Action で Cookie を読みます。流入元はページを移動しても残り、フォームに現れることはありません。

### フォーム Action のある SvelteKit のページはプリレンダーできますか？

できません。Action のあるページは POST に応答するサーバーが必要なので、SvelteKit はプリレンダーを拒否します。他のページをプリレンダーし、フォームのページはサーバーレンダリングのままにしてください。

### waitlist_joined の Cookie で重複登録を防げますか？

そのブラウザーでだけです。Ikiform はメールアドレスで重複排除しないので、リストを管理している場所かエクスポート時に重複を取り除いてください。

### Ikiform は無料ですか？

いいえ、無料プランはありません。テストは無料で、支払う前に localhost からテスト送信を月 50 件まで送れます。本番運用には、500 件で月 $6 からの Pro か、買い切りで 10,000 件 $49、または 50,000 件 $149 を選べます。クレジットに有効期限はありません。

## その他のガイド

- [新しい SaaS のウェイトリスト](https://ikiform.com/ja/guides/waitlist.md): 自分のランディングページにローンチ前のウェイトリストを作ります。フォーム、スパム対策、重複、確認メール、ローンチ日のきれいなエクスポートまで扱います。
- [Next.js のウェイトリスト](https://ikiform.com/ja/guides/waitlist/nextjs.md): Next.js App Router のウェイトリスト。シークレットキーで登録を転送するサーバーアクション、useActionState、楽観的なサンクス表示、訪問者ごとの制限を扱います。
- [Astro のウェイトリスト](https://ikiform.com/ja/guides/waitlist/astro.md): 静的なランディングページに Astro のウェイトリストを。Action が登録を転送し、スクリプトがそれを呼び、オンデマンドの 1 ページが JavaScript なしの送信を受け止めます。
- [Product Hunt ローンチリスト](https://ikiform.com/ja/guides/waitlist/product-hunt.md): Product Hunt の数週間前からローンチリストを集め、登録をチャネルごとにタグ付けし、太平洋時間のリセットに合わせてローンチ日のメールを 2 回に分けて送ります。
- [選考付きのベータ登録](https://ikiform.com/ja/guides/waitlist/beta-signup.md): サーバーが必須を強制する選考質問付きのベータ応募フォームと、応募者を採点してリストから各コホートを選ぶスクリプトを紹介します。
- [モバイルアプリのウェイトリスト](https://ikiform.com/ja/guides/waitlist/mobile-app.md): 使っているスマホを尋ね、iPhone のテスターを TestFlight に送り、Play のクローズドテストを埋める、iOS・Android アプリ向けの公開前ウェイトリストです。
