# SvelteKit でニュースレター登録フォームを作る

> レイアウトに置く SvelteKit 3 のニュースレターフォームです。同意と IP ごとの制限を確認するフォームアクション、ページにとどまる use:enhance、Kit 向けの Webhook のルートを組み合わせます。

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

フォームアクションは `/newsletter` ページに置き、ルートレイアウトのフォームからそこへ送信します。`use:enhance` で `update({ navigate: false })` を使うと結果は読者がいるページに適用されるので、フッターは記事を離れずに結果を表示し、JavaScript がなければ同じ送信が `/newsletter` に着地します。アクションは同意を確認し、`getClientAddress()` で IP ごとに制限して、登録をフォームバックエンドに転送します。`+server.ts` のルートが検証済みの登録を Kit に渡します。

[メインガイドの SvelteKit タブ](https://ikiform.com/ja/guides/newsletter-signup.md#form)では、フッターから `/newsletter` のアクションに送信し、結果を手作業で処理しています。このページでは SvelteKit 3 のやり方で作ります。明示的な環境変数、フレームワークに結果を適用させる `update({ navigate: false })`、IP ごとの制限、Kit 向けの Webhook のルートです。製品の設定については [SvelteKit のドキュメントガイド](https://ikiform.com/ja/docs/guides/sveltekit.md)にあります。

## SvelteKit でレイアウトにフォームを置くと何が変わるか

フォームアクションは `+page.server.ts` にしか置けず、レイアウトには置けないので、フッターのフォームは別のページのアクションに送信します。SvelteKit 3 では、ここで拡張したフォームもネイティブのフォームと同じように振る舞うようになりました。アクションが別のページにあるフォームは、そのページに遷移します。ドキュメントでこれを無効にする例として挙げられているのがニュースレターのウィジェットで、`update({ navigate: false })` を使います。結果は現在のページに `page.form` として適用され、フッターがそれを読みます。

図: SvelteKit のフッターからの登録が、読者のいるページに戻ってくる仕組み

1. 読者がフッターのフォームを送信します。use:enhance がバックグラウンドで /newsletter の subscribe アクションに送ります。
2. アクションは同意のチェックボックスを確認し、次に読者の IP からの最近の試行回数を数えます。
3. 上限を超えていれば fail(429) を返し、何も送りません。
4. そうでなければ、シークレットキーとブラウザーの Origin を付けて登録を Ikiform に送信します。
5. navigate を false にした update が結果を現在のページに page.form として適用し、フッターがそれを表示します。JavaScript がなければブラウザーは /newsletter に移動し、そこで同じコンポーネントが描画されます。

反応するのは source が結果と一致するフォームだけなので、フッターとページのフォームが両方とも変わることはありません。

## プロジェクトの準備

`プロジェクトの構成`:

```text
src/
  env.ts
  lib/
    NewsletterForm.svelte
    server/limit.ts
  routes/
    +layout.svelte
    newsletter/
      +page.server.ts
      +page.svelte
    api/ikiform/+server.ts
.env
```

SvelteKit 3 では `$env/*` モジュールが非推奨になり、明示的な変数を使うようになりました。`src/env.ts` で宣言します。デフォルトで非公開かつ実行時に読み込まれ、`$app/env/private` はブラウザーのコードへのインポートを拒否します。

`src/env.ts`:

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

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

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567
```

**API keys** で `submissions:write` スコープの Ikiform のキーを作り、[ピラーのフィールド定義](https://ikiform.com/ja/guides/newsletter-signup.md#fields)をフォームの **Fields** タブに貼り付けます。

## 作ってみる

### IP ごとの制限

人が購読するのは 1 回です。10 分間に 3 回までなら打ち間違いの余地を残しつつ、スクリプトは止められます。`getClientAddress()` はアダプターが判断した IP を返すので、制限の処理はホストがどのヘッダーを使うかを知る必要がありません。

`src/lib/server/limit.ts`:

```ts
const WINDOW_MS = 10 * 60_000;
const LIMIT = 3;
const recent = new Map<string, number[]>();

export function allow(address: string, now = Date.now()) {
  const hits = (recent.get(address) ?? []).filter((time) => now - time < WINDOW_MS);
  if (hits.length >= LIMIT) return false;
  if (recent.size > 10_000) recent.clear();
  recent.set(address, [...hits, now]);
  return true;
}
```

カウンターは 1 台のサーバーのメモリにあります。サーバーレスのホストではインスタンスごとに別々に数えるので、減速帯程度に考え、トラフィックが増えたら共有のストアに移してください。`server` ディレクトリのファイルはブラウザーのコードからインポートできません。SvelteKit 3 では、新しいプロジェクトが `package.json` の `imports` で宣言する `#lib` エイリアスで `src/lib` を参照します。古い `$lib` エイリアスはなくなりました。

### Action

`src/routes/newsletter/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { allow } from "#lib/server/limit.js";
import type { Actions } from "./$types";

export const prerender = false;

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export const actions = {
  subscribe: async ({ request, fetch, getClientAddress, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    const picked = String(form.get("source") ?? "");
    const source = PLACEMENTS.includes(picked) ? picked : "footer";
    const refuse = (status: number, field: string, message: string) =>
      fail(status, { newsletter: { ok: false, source, field, message, email } });

    if (form.get("consent") !== "true") return refuse(422, "consent", "Tick the box so we know you want the emails.");
    if (!allow(getClientAddress())) return refuse(429, "form", "Too many tries from this connection. Wait a few minutes.");

    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,
        first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
        consent: true,
        source,
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
    }).catch(() => null);

    const result = await response?.json().catch(() => null);
    if (result?.ok) return { newsletter: { ok: true, source } };
    const emailError = result?.error?.fields?.email?.[0];
    if (emailError) return refuse(422, "email", emailError);
    return refuse(response?.status ?? 502, "form", result?.error?.message ?? "We couldn't reach the list. Try again.");
  },
} satisfies Actions;
```

- 結果は `newsletter` で包まれているので、`page.form` でほかのフォームの結果と衝突することはありません。
- `fail()` は入力されたメールアドレスを残します。SvelteKit 3 は拡張したフォームでも `fail()` のステータスを送るので、ネットワークタブには `422` や `429` が表示されます。
- Ikiform は無料の `localhost` テストをそのヘッダーで判別し、サーバーの `fetch` は Origin を送らないので、`Origin` の行でブラウザーのオリジンを引き継ぎます。
- ルートレイアウトがすべてをプリレンダーする場合は `prerender = false` が重要です。アクションを持つページはプリレンダーできません。

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

### フォームコンポーネント

`src/lib/NewsletterForm.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { page } from "$app/state";

  type Outcome = { ok: boolean; source: string; field?: string; message?: string; email?: string };

  let { source = "footer" }: { source?: "footer" | "post" | "newsletter-page" } = $props();
  let pending = $state(false);
  const outcome = $derived.by(() => {
    const value = page.form?.newsletter as Outcome | undefined;
    return value?.source === source ? value : undefined;
  });
</script>

{#if outcome?.ok}
  <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
{:else}
  <form
    method="POST"
    action="/newsletter?/subscribe"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ navigate: false, refreshAll: false, reset: false });
        pending = false;
      };
    }}
  >
    <label>
      Email
      <input name="email" type="email" required autocomplete="email" value={outcome?.email ?? ""} aria-invalid={outcome?.field === "email"} />
    </label>
    <label>
      First name (optional)
      <input name="first_name" maxlength="80" autocomplete="given-name" />
    </label>
    <label>
      <input name="consent" type="checkbox" value="true" required aria-invalid={outcome?.field === "consent"} />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <input type="hidden" name="source" value={source} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if outcome?.message}
      <p role="alert">{outcome.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
  </form>
{/if}
```

- `navigate: false` で読者は記事にとどまります。`refreshAll: false` で、登録が成功したあとにすべての `load` 関数を再実行するのを飛ばします。登録はそれらが読み込むものを何も変えないからです。
- `reset: false` で、エラーのときに入力されたメールアドレスを残します。成功したときはどのみちフォームが置き換わります。
- JavaScript がオフなら送信は `/newsletter` に向かい、そこでこの同じコンポーネントが描画され、`page.form` が結果を運びます。

### /newsletter ページ

`src/routes/newsletter/+page.svelte`:

```svelte
<script lang="ts">
  import NewsletterForm from "#lib/NewsletterForm.svelte";
</script>

<svelte:head>
  <title>Newsletter</title>
</svelte:head>

<h1>The monthly newsletter</h1>
<p>What we shipped, what broke and what we learned. One email a month.</p>
<NewsletterForm source="newsletter-page" />
```

フッター用に `+layout.svelte` で `<NewsletterForm />` を描画し、記事の最後では `source="post"` を渡します。

## 購読者を Kit に転送する

この `+server.ts` のルートは Ikiform の署名付き Webhook を受け取り、Kit で購読者を inactive として作成して Kit のフォームに追加します。そのフォームの特典メールが Kit の確認メールです。一度は自分のアドレスでテストしてください。署名はバイト列そのものが対象なので、まずボディをテキストとして読み、SvelteKit 3 では `json()` と `text()` のヘルパーが非推奨になったので、素の `Response` オブジェクトを返します。Kit の各応答の意味とタグの追加については [Kit のガイド](https://ikiform.com/ja/guides/newsletter-signup/kit.md)で説明しています。

`src/routes/api/ikiform/+server.ts`:

```ts
import { IKIFORM_WEBHOOK_SECRET, KIT_API_KEY, KIT_FORM_ID } from "$app/env/private";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { RequestHandler } from "./$types";

export const POST: RequestHandler = async ({ request, fetch }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const kit = (path: string, body: Record<string, unknown>) =>
    fetch(`https://api.kit.com/v4${path}`, {
      method: "POST",
      headers: { "X-Kit-Api-Key": KIT_API_KEY, "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify(body),
    });

  const created = await kit("/subscribers", {
    email_address: email,
    first_name: typeof first_name === "string" ? first_name : null,
    state: "inactive",
  });
  const added = created.ok ? await kit(`/forms/${KIT_FORM_ID}/subscribers`, { email_address: email }) : created;

  if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

バージョン 3 では SvelteKit の CSRF 保護は常にオンですが、拒否するのはクロスオリジンのフォーム送信と、`Content-Type` のないクロスオリジンの送信だけです。Ikiform は `application/json` を送るので、Webhook は通ります。URL を **Webhooks** に追加し、`whsec_` のシークレットをコピーします。

## ハニーポットと人間確認

`_gotcha` 入力欄は転送され、Ikiform は埋まったものを成功の応答付き・Webhook なしでスパムとして保存するので、Kit がそれを目にすることはありません。シークレットキーを使うと Ikiform の Turnstile のチェックはスキップされますが、スクリプトが繰り返す送信は上の IP ごとの制限で防げます。多くの IP を使うボットには、[Turnstile のガイド](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)のようにアクションで Turnstile のトークンを検証してください。

## デプロイ

| ホスト | 対処法 |
| --- | --- |
| Vercel | `adapter-vercel` はアクションを関数として動かします。SvelteKit 3 ではそこでのエッジランタイムがなくなったので、デフォルトを使ってください。変数は **Environment Variables** に設定します。 |
| Netlify | `adapter-netlify` は Netlify の Frameworks API に従い、公開ディレクトリは今ではアダプターのオプションです。変数はサイトの環境設定に設定します。 |
| Cloudflare | `adapter-cloudflare` は Workers 向けにビルドし、ピア依存として Wrangler 4.118 以降が必要です。ドキュメントでは変数に `$app/env` を使うことが推奨されています。Ikiform は `workers.dev` と `pages.dev` を拒否するので、Webhook には独自ドメインを使ってください。 |

## localhost で無料でテストする

プランのないワークスペースでも `localhost` からのテスト送信を月 50 件まで受け付け、転送される `Origin` によって開発サーバーからの各登録はそれに数えられます。制限の処理はあなた自身も数えるので、テスト中に上限に達したら開発サーバーを再起動してください。Ikiform は `localhost` に Webhook を配信しないので、ルートをテストするには HTTPS の URL が得られるトンネルで開発サーバーを公開してください。

## 動作を確認する

1. `vite dev` を実行し、任意のページを開いてフッターから購読します。フッターに一文が表示され、URL は変わりません。
2. JavaScript を無効にしてもう一度購読します。同じ一文が表示された `/newsletter` に移動します。
3. 10 分以内に 4 回送信します。4 回目で制限のメッセージが表示され、ネットワークタブには `429` が表示されます。
4. ダッシュボードで **Submissions** を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
5. Webhook を追加した場合は、**Webhooks** でエンドポイントを開きます。配信が `succeeded` になり、サーバーの応答が表示されます。
6. Kit を開きます。購読者が非アクティブとして存在し、Kit の確認メールが受信トレイに届いています。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| JavaScript があってもフッターのフォームを送信すると `/newsletter` に移動してしまう | `use:enhance` のコールバックが `navigate: false` なしで `update()` を呼んでいます。SvelteKit 3 では、別ページのアクションはデフォルトで遷移します。 |
| `/newsletter` をプリレンダーできないためビルドが失敗する | レイアウトが `prerender = true` を設定しています。アクションを持つページにはサーバーが必要です。そのページのサーバーファイルで `prerender = false` を設定してください。 |
| フッターとページのフォームの両方に結果が表示される | 両方が同じ `page.form` を読んでいます。サンプルのように、その `source` をコンポーネントのものと比較してください。 |
| ローカルのテストで `402 plan_required` が出る | 送信に `localhost` のオリジンがありませんでした。`Origin` の行を確認し、`localhost` でサイトを開いてください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。**Spam** フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |

## よくある質問

### SvelteKit のフォームアクションをレイアウトに置くには？

レイアウトには定義できません。アクションは +page.server ファイルに属します。/newsletter のようなページにアクションを置き、レイアウトから action="/newsletter?/subscribe" で送信します。現在のページにとどまるには、use:enhance で update({ navigate: false }) を使ってください。

### プリレンダーした SvelteKit のサイトにニュースレターフォームを置けますか？

はい。記事はプリレンダーし、アクションのあるページと Webhook のルートだけをサーバーに任せます。サーバーがまったくないサイトなら、代わりにブラウザーから直接 Ikiform に送信できます。

### 購読者が Webhook 経由で Kit に届くのはなぜですか？

誰が本物かを知っているのが Webhook だけだからです。フォームアクションはボットにも読者と同じ成功の応答を受け取りますが、Webhook は Ikiform のスパムチェックを通った登録にだけ送られるので、Kit がボットを目にすることはありません。

### Ikiform は無料ですか？

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

## その他のガイド

- [ニュースレター登録フォーム](https://ikiform.com/ja/guides/newsletter-signup.md): 自分のマークアップでニュースレター登録フォームを作り、同意を記録し、ボットの登録を防ぎ、購読者を Kit、MailerLite、beehiiv など任意のツールに渡します。
- [Kit (ConvertKit)](https://ikiform.com/ja/guides/newsletter-signup/kit.md): 購読者を作成し、特典メールのために Kit のフォームに追加し、設置場所のタグを付けるリレーで、自作のニュースレターフォームを Kit につなぎます。
- [ダブルオプトイン](https://ikiform.com/ja/guides/newsletter-signup/double-opt-in.md): Ikiform は確認メールを送らないので、ダブルオプトインはメールツールで行います。中継が各登録を未確認として追加し、ツールがリンクを送ります。
- [Nuxt のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/nuxt.md): JavaScript の有無にかかわらず動く Nuxt のニュースレターフォームです。Turnstile をチェックするサーバールートが Ikiform に送信し、Nitro のルートが beehiiv に登録を渡します。
- [静的サイト](https://ikiform.com/ja/guides/newsletter-signup/static-site.md): Hugo、Jekyll、Eleventy、Astro の静的サイトにニュースレター登録フォームを追加し、メールツールの API キーをページに入れず、確認はツールで行います。
- [MailerLite](https://ikiform.com/ja/guides/newsletter-signup/mailerlite.md): 自作の登録フォームを MailerLite につなぎます。リレーが新しい読者をダブルオプトインのために未確認として追加し、購読者に二度確認を求めることはありません。
