ニュースレター登録フォーム

ニュースレター登録フォーム3 分で読めます

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

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

Preet SutharIkiform 創業者

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

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

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

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

SvelteKit のフッターからの登録が、読者のいるページに戻ってくる仕組みフッターのフォーム+layout.svelte、任意のページ?/subscribe/newsletterのアクションgetClientAddress()10分あたり3回までIkiformシークレットキー、登録を保存page.form現在のページにfail(429)メッセージ、何も送らないuse:enhance許可navigate: false上限超過SvelteKit のフッターからの登録が、読者のいるページに戻ってくる仕組みフッターのフォーム+layout.svelte、任意のページ?/subscribe/newsletter のアクションgetClientAddress()10 分あたり 3 回までIkiformシークレットキー、登録を保存page.form現在のページにfail(429)メッセージ、何も送らないuse:enhance許可navigate: false上限超過
  1. 読者がフッターのフォームを送信します。use:enhance がバックグラウンドで /newsletter の subscribe アクションに送ります。
  2. アクションは同意のチェックボックスを確認し、次に読者の IP からの最近の試行回数を数えます。
  3. 上限を超えていれば fail(429) を返し、何も送りません。
  4. そうでなければ、シークレットキーとブラウザーの Origin を付けて登録を Ikiform に送信します。
  5. navigate を false にした update が結果を現在のページに page.form として適用し、フッターがそれを表示します。JavaScript がなければブラウザーは /newsletter に移動し、そこで同じコンポーネントが描画されます。
反応するのは source が結果と一致するフォームだけなので、フッターとページのフォームが両方とも変わることはありません。

プロジェクトの準備

プロジェクトの構成
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
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
  IKIFORM_WEBHOOK_SECRET: {},
  KIT_API_KEY: {},
  KIT_FORM_ID: {},
});
.env
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 のキーを作り、ピラーのフィールド定義をフォームの Fields タブに貼り付けます。

作ってみる

IP ごとの制限

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

src/lib/server/limit.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
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 のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

フォームコンポーネント

src/lib/NewsletterForm.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
<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 のガイドで説明しています。

src/routes/api/ikiform/+server.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 のガイドのようにアクションで Turnstile のトークンを検証してください。

デプロイ

ホスト対処法
Verceladapter-vercel はアクションを関数として動かします。SvelteKit 3 ではそこでのエッジランタイムがなくなったので、デフォルトを使ってください。変数は Environment Variables に設定します。
Netlifyadapter-netlify は Netlify の Frameworks API に従い、公開ディレクトリは今ではアダプターのオプションです。変数はサイトの環境設定に設定します。
Cloudflareadapter-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 が送られます。

よくある質問

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