ニュースレター登録フォーム3 分で読めます
SvelteKit でニュースレター登録フォームを作る
レイアウトに置く SvelteKit 3 のニュースレターフォームです。同意と IP ごとの制限を確認するフォームアクション、ページにとどまる use:enhance、Kit 向けの Webhook のルートを組み合わせます。
フォームアクションは /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 として適用され、フッターがそれを読みます。
- 読者がフッターのフォームを送信します。use:enhance がバックグラウンドで /newsletter の subscribe アクションに送ります。
- アクションは同意のチェックボックスを確認し、次に読者の IP からの最近の試行回数を数えます。
- 上限を超えていれば fail(429) を返し、何も送りません。
- そうでなければ、シークレットキーとブラウザーの Origin を付けて登録を Ikiform に送信します。
- navigate を false にした update が結果を現在のページに page.form として適用し、フッターがそれを表示します。JavaScript がなければブラウザーは /newsletter に移動し、そこで同じコンポーネントが描画されます。
プロジェクトの準備
src/
env.ts
lib/
NewsletterForm.svelte
server/limit.ts
routes/
+layout.svelte
newsletter/
+page.server.ts
+page.svelte
api/ikiform/+server.ts
.envSvelteKit 3 では $env/* モジュールが非推奨になり、明示的な変数を使うようになりました。src/env.ts で宣言します。デフォルトで非公開かつ実行時に読み込まれ、$app/env/private はブラウザーのコードへのインポートを拒否します。
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
IKIFORM_WEBHOOK_SECRET: {},
KIT_API_KEY: {},
KIT_FORM_ID: {},
});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=1234567API keys で submissions:write スコープの Ikiform のキーを作り、ピラーのフィールド定義をフォームの Fields タブに貼り付けます。
作ってみる
IP ごとの制限
人が購読するのは 1 回です。10 分間に 3 回までなら打ち間違いの余地を残しつつ、スクリプトは止められます。getClientAddress() はアダプターが判断した IP を返すので、制限の処理はホストがどのヘッダーを使うかを知る必要がありません。
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
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_SECRET_KEY はサーバー専用の環境変数に置いてください。フォームコンポーネント
<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 ページ
<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 のガイドで説明しています。
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 のトークンを検証してください。
デプロイ
| ホスト | 対処法 |
|---|---|
| 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 が得られるトンネルで開発サーバーを公開してください。
動作を確認する
vite devを実行し、任意のページを開いてフッターから購読します。フッターに一文が表示され、URL は変わりません。- JavaScript を無効にしてもう一度購読します。同じ一文が表示された
/newsletterに移動します。 - 10 分以内に 4 回送信します。4 回目で制限のメッセージが表示され、ネットワークタブには
429が表示されます。 - ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- Webhook を追加した場合は、Webhooks でエンドポイントを開きます。配信が
succeededになり、サーバーの応答が表示されます。 - 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 が送られます。 |
よくある質問
レイアウトには定義できません。アクションは +page.server ファイルに属します。/newsletter のようなページにアクションを置き、レイアウトから action="/newsletter?/subscribe" で送信します。現在のページにとどまるには、use:enhance で update({ navigate: false }) を使ってください。
はい。記事はプリレンダーし、アクションのあるページと Webhook のルートだけをサーバーに任せます。サーバーがまったくないサイトなら、代わりにブラウザーから直接 Ikiform に送信できます。
誰が本物かを知っているのが Webhook だけだからです。フォームアクションはボットにも読者と同じ成功の応答を受け取りますが、Webhook は Ikiform のスパムチェックを通った登録にだけ送られるので、Kit がボットを目にすることはありません。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com