ガイド4 分で読めます
Mailchimp の埋め込みを使わないニュースレター登録フォーム
自分のマークアップでニュースレター登録フォームを作り、同意を記録し、ボットの登録を防ぎ、購読者を Kit、MailerLite、beehiiv など任意のツールに渡します。
メール配信サービスの埋め込みコードを貼る代わりに、登録フォームを自分の HTML で書き、フォームバックエンドに向けます。バックエンドがアドレスと同意のチェックボックスを検証し、ボットを除外して署名付きの Webhook を送り、小さなリレーがメールツールの API で購読者を追加します。確認メール、ニュースレター、配信停止リンクはメールツールが送るので、あとでツールを変えるときも、変えるのはリレーだけで、すべてのフォームではありません。
どのメールツールにも埋め込みがあります。ページに貼り付ける、生成されたマークアップのかたまりです。動きはしますが、フォームはそのツールのものになります。送信先はそのプロバイダーで、プロバイダーのマークアップや、しばしばスタイルやスクリプトも一緒に入り、別のツールに移る日にはサイト中のコピーを探し回ることになります。このガイドでは、すでに使っているマークアップとフレームワークで自分のものと言える登録フォームを作り、ニュースレターを送るツールにつなぎます。
登録の流れ
ここには 2 つの仕事があり、それぞれ置き場所が違います。登録を集めるのはフォームの仕事です。アドレスを検証し、同意のチェックを確認し、ボットを防ぎ、いつ登録されたかを記録します。ニュースレターを送るのはメールの仕事です。アドレスを確認し、各号を送り、配信停止とバウンスを処理します。前者は Ikiform が、後者はメールツールが担います。両者を署名付きの Webhook がつなぎます。
- 読者がページの登録フォームに入力すると、Ikiform のエンドポイントに送信されます。
- Ikiform はアドレス、必須の同意チェックボックス、ハニーポット、スパムスコア、レート制限をチェックします。
- 受け付けた登録は、タイムスタンプ、origin、リファラーとともにダッシュボードに保存されます。
- Ikiform は登録ごとにあなたにメールで知らせることができます。読者にメールを送ることはありません。
- Ikiform が署名付きの submission.created Webhook を送ります。
- リレーが署名を検証して読者をメールツールに追加し、メールツールが確認メールを、のちには毎号を送ります。
ニュースレター登録を集める 4 つの方法
| 方法 | 得意なこと | トレードオフ |
|---|---|---|
| Mailchimp、Kit、MailerLite、beehiiv などメールツールの埋め込み | 運用するものがありません。確認、タグ付け、ウェルカムメールはツール自身の設定でつながっています。 | マークアップと挙動はツールのものです。Mailchimp によると埋め込みフォームはサイトのスタイルを継承するので、ページ上で見た目が変わることがあり、既存のフォームで reCAPTCHA をオンにするには新しいコードを生成して貼り直す必要があります。どのコピーも 1 つのプロバイダーに縛られます。 |
| ツールの API を呼ぶ自前のサーバールート | フォームとリクエストを完全に制御でき、API キーはサーバーに置いたままにできます。 | 検証、ボット除外、レート制限、同意の記録を自分で作る必要があり、サイトが静的でもサーバーが要ります。 |
| Typeform と Zapier のような、ホスト型フォームビルダーと自動化の組み合わせ | コード不要で、自動化ツールはあらゆるメール配信サービスに対応しています。 | ページになじまない iframe と、2 つのサブスクリプション。Webhook やブランディングはプランしだいです。 |
| Ikiform のようなフォームバックエンドとリレー | どのサイトでも自分のマークアップを使え、スパム除外、保存された同意の記録、署名付き Webhook があり、フォーム用のサーバーは不要です。 | Ikiform は読者にメールを送りません。確認メールと毎号のニュースレターは、小さなリレーか自動化を通してメールツールから送られます。 |
サイトが 1 つ、ツールが 1 つで、その埋め込みに満足していますか? それならそのままで構いません。フォームをデザインに合わせる必要があるとき、複数のサイトにまたがるとき、ツールより長く使うかもしれないときは、自分で作りましょう。
フォーム
良いニュースレターフォームは小さなものです。メールアドレス、挨拶に使う任意の名前、そしてラベルがそのまま約束になる同意のチェックボックスです。hidden の source 入力欄で、サイトのどこで登録されたかを記録します。スタックを選んでください。選択はこのガイドのすべてのコードサンプルに引き継がれます。
フォームをフッター、サイドバー、記事の末尾に貼り付けます。普通の方法で送信して自分のサンクスページに移動するので、JavaScript なしでも動きます。
<form id="newsletter" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" />
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" autocomplete="given-name" />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value="footer" />
<input type="hidden" name="_redirect" value="https://example.com/subscribed" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Subscribe</button>
<p id="nl-status" role="status" hidden></p>
</form>読者を記事にとどめるには、フォームの後ろにこのスクリプトを追加します。fetch で送信し、フォームを受信箱の確認を促す一文に置き換え、メールアドレスやチェックボックスについてはサーバーのメッセージを表示します。
const form = document.querySelector("#newsletter");
const status = document.querySelector("#nl-status");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const body = new FormData(form);
button.disabled = true;
status.hidden = false;
status.textContent = "Subscribing";
try {
const response = await fetch(form.action, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
form.replaceChildren(status);
status.textContent = "Almost done. Open the email we sent you and confirm your address.";
return;
}
const fields = result.error.fields ?? {};
status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? result.error.message;
} catch {
status.textContent = "Couldn't reach the server. Try again.";
}
button.disabled = false;
});サーバーアクションがシークレットキーで登録を転送します。エラーはフィールドごとに返るので、メールアドレスのメッセージはメール入力欄の下に、同意のメッセージはチェックボックスの下に表示されます。コンポーネントは source プロパティを受け取るので、フッターと記事内のボックスで別の値を記録できます。
"use server";
import { headers } from "next/headers";
export interface SignupState {
done: boolean;
errors: { email?: string; consent?: string; form?: string };
email: string;
}
export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim();
const consent = formData.get("consent") === "true";
if (!consent) return { done: false, errors: { consent: "Tick the box so we know you want the emails." }, email };
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: (await headers()).get("origin") ?? "",
},
body: JSON.stringify({
email,
first_name: String(formData.get("first_name") ?? "").trim() || undefined,
consent,
source: String(formData.get("source") ?? "footer"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { done: true, errors: {}, email };
const fields = result?.error?.fields ?? {};
return {
done: false,
errors: {
email: fields.email?.[0],
consent: fields.consent?.[0],
form: fields.email || fields.consent ? undefined : (result?.error?.message ?? "Couldn't subscribe. Try again."),
},
email,
};
}"use client";
import { useActionState } from "react";
import { type SignupState, subscribe } from "./actions";
const initial: SignupState = { done: false, errors: {}, email: "" };
export function NewsletterForm({ source = "footer" }: { source?: string }) {
const [state, action, pending] = useActionState(subscribe, initial);
if (state.done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<form action={action} noValidate>
<label htmlFor="nl-email">Email</label>
<input
id="nl-email"
name="email"
type="email"
required
autoComplete="email"
defaultValue={state.email}
aria-invalid={Boolean(state.errors.email)}
aria-describedby="nl-email-error"
/>
<p id="nl-email-error">{state.errors.email}</p>
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} autoComplete="given-name" />
<label>
<input name="consent" type="checkbox" value="true" aria-describedby="nl-consent-error" />
Email me the newsletter. I can unsubscribe at any time.
</label>
<p id="nl-consent-error">{state.errors.consent}</p>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}Next.js でさらに詳しく知るには Next.js でニュースレター登録フォームを作る を読んでください。
accept: "form" を指定した Astro Action は、チェックボックスを boolean として、メールアドレスを z.email() で解析します。isInputError で、不正なアドレスと拒否された送信を区別できます。フォームの Action にはオンデマンドのページが必要なので、アダプターを追加し、コンポーネントを描画するすべてのページで export const prerender = false を設定してください。コンポーネントの中でエクスポートしても効果はありません。
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
subscribe: defineAction({
accept: "form",
input: z.object({
email: z.email(),
first_name: z.string().max(80).nullish(),
consent: z.boolean(),
source: z.string().default("footer"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
if (!input.consent) {
throw new ActionError({ code: "BAD_REQUEST", message: "Tick the box so we know you want the emails." });
}
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...input, first_name: input.first_name ?? undefined }),
});
const result = await response.json();
if (!result.ok) {
throw new ActionError({
code: "BAD_REQUEST",
message: result.error.fields?.email?.[0] ?? result.error.message,
});
}
return { subscribed: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
---
{result?.data?.subscribed ? (
<p role="status">Almost done. Open the email we sent you and confirm your address.</p>
) : (
<form method="POST" action={actions.subscribe}>
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" />
{fields.email && <p>Enter a valid email address.</p>}
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" />
<label>
<input name="consent" type="checkbox" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Subscribe</button>
</form>
)}Astro でさらに詳しく知るには Astro でニュースレター登録フォームを作る を読んでください。
createServerFn の POST 関数は .validator() で FormData を JSON に変え、フィールドのエラーを例外ではなくデータとして返すので、コンポーネントは各メッセージを入力欄の隣に置けます。
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Errors = { email?: string; consent?: string; form?: string };
const subscribe = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
email: String(data.get("email") ?? "").trim(),
first_name: String(data.get("first_name") ?? "").trim() || undefined,
consent: data.get("consent") === "true",
source: String(data.get("source") ?? "footer"),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }): Promise<{ ok: boolean; errors: Errors }> => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.ok) return { ok: true, errors: {} };
const fields = result.error.fields ?? {};
return {
ok: false,
errors: { email: fields.email?.[0], consent: fields.consent?.[0], form: result.error.message },
};
});
export function NewsletterForm() {
const send = useServerFn(subscribe);
const [done, setDone] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
const result = await send({ data: new FormData(event.currentTarget) });
setDone(result.ok);
setErrors(result.errors);
} catch {
setErrors({ form: "Couldn't subscribe. Try again." });
} finally {
setPending(false);
}
}
if (done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autoComplete="email" />
{errors.email ? <p>{errors.email}</p> : null}
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
{errors.consent ? <p>{errors.consent}</p> : null}
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form && !errors.email && !errors.consent ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}TanStack Start でさらに詳しく知るには TanStack Start でニュースレター登録フォームを作る を読んでください。
フッターのフォームが、読んでいるページから移動させてはいけません。action を /newsletter のリソースルートに置き、useFetcher でそこへ送信します。これはナビゲーションなしで送信し、結果を現在のページにとどめます。
import { data } from "react-router";
import type { Route } from "./+types/newsletter";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email: String(formData.get("email") ?? "").trim(),
first_name: String(formData.get("first_name") ?? "").trim() || undefined,
consent: formData.get("consent") === "true",
source: String(formData.get("source") ?? "footer"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { ok: true as const };
return data(
{ ok: false as const, fields: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) },
{ status: response.status },
);
}import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [index("routes/home.tsx"), route("newsletter", "routes/newsletter.tsx")] satisfies RouteConfig;import { useFetcher } from "react-router";
import type { action } from "~/routes/newsletter";
export function NewsletterForm() {
const fetcher = useFetcher<typeof action>();
const result = fetcher.data;
const busy = fetcher.state !== "idle";
if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<fetcher.Form method="post" action="/newsletter">
<label htmlFor="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autoComplete="email" />
{result?.fields.email ? <p>{result.fields.email[0]}</p> : null}
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
{result?.fields.consent ? <p>{result.fields.consent[0]}</p> : null}
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{result && !result.fields.email && !result.fields.consent ? <p role="alert">{result.message}</p> : null}
<button disabled={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
</fetcher.Form>
);
}React Router でさらに詳しく知るには React Router でニュースレター登録フォームを作る を読んでください。
action はレイアウトではなく必ず +page.server.ts に置くので、action は /newsletter ページに置き、フッターは /newsletter?/subscribe に送信します。独自の use:enhance コールバックは update() を呼ばずに結果を自分で処理するので、読者は記事にとどまります。$app/env/private から読めるよう、src/env.ts で defineEnvVars を使って IKIFORM_SECRET_KEY を宣言してください。
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
subscribe: async ({ request, fetch, url }) => {
const form = await request.formData();
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: String(form.get("email") ?? "").trim(),
first_name: String(form.get("first_name") ?? "").trim() || undefined,
consent: form.get("consent") === "true",
source: String(form.get("source") ?? "footer"),
_gotcha: String(form.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
return fail(response.status, { message: String(fields.email?.[0] ?? fields.consent?.[0] ?? result.error.message) });
}
return { subscribed: true };
},
} satisfies Actions;<script lang="ts">
import { applyAction, enhance } from "$app/forms";
let done = $state(false);
let message = $state("");
let pending = $state(false);
</script>
{#if done}
<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 ({ result }) => {
pending = false;
if (result.type === "success") done = true;
else if (result.type === "failure") message = String(result.data?.message ?? "Couldn't subscribe.");
else await applyAction(result);
};
}}
>
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" />
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if message}
<p role="alert">{message}</p>
{/if}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
{/if}SvelteKit でさらに詳しく知るには SvelteKit でニュースレター登録フォームを作る を読んでください。
サーバールートがシークレットキーをブラウザーから隠し、Ikiform のフィールドエラーをエラーの data で返します。NUXT_IKIFORM_SECRET_KEY を設定し、nuxt.config.ts の runtimeConfig に ikiformSecretKey を宣言してください。
interface Signup {
email?: string;
first_name?: string;
consent?: boolean;
source?: string;
_gotcha?: string;
}
export default defineEventHandler(async (event) => {
const body = await readBody<Signup>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: body.email?.trim() ?? "",
first_name: body.first_name?.trim() || undefined,
consent: body.consent === true,
source: body.source ?? "footer",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { subscribed: true };
});<script setup lang="ts">
const email = ref("");
const firstName = ref("");
const consent = ref(false);
const trap = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");
async function subscribe() {
status.value = "sending";
errors.value = {};
message.value = "";
try {
await $fetch("/api/newsletter", {
method: "POST",
body: { email: email.value, first_name: firstName.value, consent: consent.value, source: "footer", _gotcha: trap.value },
});
status.value = "done";
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
errors.value = data?.data?.fields ?? {};
message.value = data?.message ?? "Couldn't subscribe. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
<form v-else @submit.prevent="subscribe">
<label for="nl-email">Email</label>
<input id="nl-email" v-model="email" name="email" type="email" required autocomplete="email" />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="nl-name">First name (optional)</label>
<input id="nl-name" v-model="firstName" name="first_name" type="text" maxlength="80" />
<label>
<input v-model="consent" name="consent" type="checkbox" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<p v-if="errors.consent">{{ errors.consent[0] }}</p>
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="message && !errors.email && !errors.consent" role="alert">{{ message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
</form>
</template>Nuxt でさらに詳しく知るには Nuxt でニュースレター登録フォームを作る を読んでください。
シングルページアプリはブラウザーから送信します。@ikiform/sdk の submit() は FormData を送り、常に ok と、失敗時にはフィールドのエラーを返して解決するので、try ブロックを書く必要はありません。ここには絶対にシークレットキーを入れないでください。代わりに Allowed domains がフォームを守ります。
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";
type FieldErrors = Record<string, string[]>;
export function NewsletterForm() {
const [done, setDone] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const [message, setMessage] = useState("");
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setErrors({});
setMessage("");
const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(event.currentTarget));
setPending(false);
if (result.ok) {
setDone(true);
return;
}
setErrors(result.error.fields ?? {});
setMessage(result.error.message);
}
if (done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" onSubmit={onSubmit}>
<label htmlFor="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autoComplete="email" />
{errors.email ? <p>{errors.email[0]}</p> : null}
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
{errors.consent ? <p>{errors.consent[0]}</p> : null}
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{message && !errors.email && !errors.consent ? <p role="alert">{message}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}Vue の単一ファイルコンポーネントで同じ submit() を呼びます。フォームは action を保持しているので、スクリプトの読み込みに失敗しても送信できます。
<script setup lang="ts">
import { submit } from "@ikiform/sdk";
import { ref } from "vue";
const status = ref<"idle" | "sending" | "done">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");
async function subscribe(event: SubmitEvent) {
status.value = "sending";
const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(event.target as HTMLFormElement));
if (result.ok) {
status.value = "done";
return;
}
errors.value = result.error.fields ?? {};
message.value = result.error.message;
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
<form v-else action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" @submit.prevent="subscribe">
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<p v-if="errors.consent">{{ errors.consent[0] }}</p>
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="message && !errors.email && !errors.consent" role="alert">{{ message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
</form>
</template>frm_xxxxxxxxxxxxxxxxをダッシュボードにある自分のフォームの ID に置き換えます。- メール入力欄の name は
emailのままにしてください。Ikiform はこれを検証し、ドメインを小文字にして、使い捨てメールのドメインかどうかをチェックします。 - hidden の
_gotcha入力欄がハニーポットです。人には見えず、すべての入力欄を埋めるボットは自らスパムだと名乗ることになります。 - チェックボックスはチェックすると
trueを送ります。チェックしなければ何も送らず、必須のチェックボックスはThis box must be checkedで拒否されます。 - メールツールが確認メールを送るので、成功の一文では受信箱を確認するよう伝えます。ダブルオプトインをオフにしているなら、普通のお礼に変えてください。
- サーバー側の版はブラウザーの
Originヘッダーを引き継ぎます。Ikiform は無料のlocalhostテストをこれで判別しますが、サーバー自身のfetchは何も送らないからです。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。フレームワークの設定はドキュメントにあります:Next.js、Astro、SvelteKit、Nuxt、React、Vue、プレーンな HTML。ニュースレターフォームのテンプレートは、同じフィールドを HTML、React、Next.js のサーバーアクションとして生成します。
フィールドと定義
下の定義をフォームの Fields タブに貼り付けると、ページが何を送ってきても Ikiform がサーバー側でそれを適用します。プレビューは同じバリデーターをブラウザーで実行します。チェックを入れずに送信すると、ボットや壊れたページが受け取るエラーを確認できます。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
source は hidden 入力欄です。ここでは試せるように表示されるテキストフィールドにしています。| フィールド | 理由 | 注意点 |
|---|---|---|
email | 購読者 | 必須。type="email" でスマートフォンに適切なキーボードが出る |
first_name | 各号の挨拶 | 任意。ほとんどのメールツールに対応づけられる名前フィールドがあります |
consent | 本人がメールを希望したという記録 | 必須のチェックボックス。true として保存 |
source | サイトのどのフォームで登録されたか:フッター、記事、ポップアップ | hidden。設置場所ごとに別の値を設定し、タグやフィールドとして渡します |
フィールドを定義すると、Ikiform は知らない入力を unknown_field で拒否するので、ページに入力欄を追加する前に定義にフィールドを追加してください。_redirect のように _ で始まる名前は Ikiform への指示で、フィールドには数えられません。
同意を記録する
ニュースレターは本人が希望したメールです。チェックボックスでその希望を明示し、保存された送信データがそれを記録します。受け付けた登録ごとに Ikiform が保持するのは次の内容です。
{
"email": "ada@example.com",
"first_name": "Ada",
"consent": true,
"source": "footer"
}フィールドのほかに、各送信データには created_at のタイムスタンプ、ページの origin とリファラー、国、ブラウザーのユーザーエージェントがあります。これらを合わせると、いつどこでチェックが入れられたかがわかります。この記録をあとで役立てるための習慣が 2 つあります。
- ラベルは約束として書きます。 「ニュースレターをメールで受け取ります。いつでも配信を停止できます。」なら、何が届くかが伝わります。お知らせやオファーも送るなら、ラベルにそう書いてください。
- 文言を変えたら日付を記録します。 保存された
trueは本人が同意したことを示しますが、どの文言にかは示しません。ラベルの変更履歴を日付付きで短く残しておけば、どの登録も、そのとき見た文言と照合できます。
Ikiform は記録を保持しますが、あなたや読者が住む地域のルールが何を求めるかは教えられません。詳しい人に確認してください。
確認メールはメールツールから送る
Ikiform はフォームに入力した本人にメールを送りません。通知メールはあなたとチームだけに届くので、自動返信、確認メール、ダブルオプトインはありません。それらは Webhook をきっかけにメールツールから送ってください。この分担は意図的なものです。ニュースレターを送るツールは、誰が確認し、誰が配信停止し、どのアドレスがバウンスしたかをすでに把握しています。別のシステムが確認メールを送ると、それらの記録がずれていきます。
そのためリレーは、メールツールが独自の確認メールを送る状態で読者を追加し、読者がクリックするとツールがその人を有効にします。これを有効にする方法はツールごとに違い、フォームの設定、アカウントのトグル、API リクエストのフラグのいずれかです。ダブルオプトインのガイドでは 3 つすべてと、既存の購読者がもう一度登録したときの動作を扱っています。
リレー
リレーは、Ikiform の Webhook を受け取り、署名をチェックしてメールツールの API を呼ぶ短い関数です。公開された HTTPS の URL と、両側に 1 つずつのシークレットが必要です。これは Next.js のルートハンドラーですが、同じコードは fetch がある任意のランタイムで動きます。
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { addSubscriber } from "@/lib/email-tool";
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.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 fields = event.data.fields;
if (fields.consent !== true) return new Response("No consent, skipped", { status: 200 });
const answer = await addSubscriber({
email: String(fields.email),
firstName: typeof fields.first_name === "string" ? fields.first_name : undefined,
source: typeof fields.source === "string" ? fields.source : "unknown",
referrer: typeof event.data.meta.referrer === "string" ? event.data.meta.referrer : undefined,
});
if (answer.status === 429) {
return new Response("Email tool rate limit", {
status: 503,
headers: { "retry-after": answer.headers.get("retry-after") ?? "60" },
});
}
if (!answer.ok) return new Response(`Email tool answered ${answer.status}`, { status: 502 });
return new Response(null, { status: 204 });
}verifyWebhookは HMAC 署名とタイムスタンプをチェックします。これがないと、URL を見つけた誰でもリストにアドレスを追加できます。- ダッシュボードの Send test による
endpoint.testイベントには204を返し、処理を飛ばします。 - 同意のチェックは、フォームの定義の後ろにある 2 つ目の鍵です。
- ツールに依存するのは
addSubscriberだけです。ツールごとの中身は Kit、MailerLite、beehiiv の各ページにあります。
リレーの応答によって、Ikiform の次の動作が決まります。
- メールツールが購読者を追加または更新した場合:リレーは 204 を返し、配信は succeeded になります。
- メールツールがレート制限中の場合:リレーは Retry-After 付きで 503 を返し、Ikiform はその時間だけ待ってから次を試みます。
- メールツールがリクエストを拒否したかダウンしている場合:リレーは 502 を返し、Ikiform は同じ webhook-id でスケジュールどおりにリトライします。
ダッシュボードの Webhooks にリレーの URL を追加し、ニュースレターのフォームを選んで、whsec_ のシークレットを IKIFORM_WEBHOOK_SECRET にコピーします。ワークスペースには最大 3 つのエンドポイントを置けます。URL はポート 443 の公開 HTTPS である必要があり、workers.dev と pages.dev のホスト名は拒否されるので、Worker にはカスタムドメインを設定してください。リトライ、ペイロード、署名の方式は Webhook のドキュメントに、各配信を 1 回だけ保存する版はフォームの送信データを自分のサーバーへにあります。
ボットとリスト爆撃を防ぐ
ボットによる登録は、ほとんどのフォーム以上にニュースレターを傷つけます。ボットが登録した実在のアドレスには頼んでもいないメールが届き、その人たちからの苦情は送信元ドメインの評判を下げます。被害者のアドレスを何百ものフォームに一斉に登録して受信箱を埋め尽くすボットもいて、確認メールを送るフォームはすべてその片棒を担ぐことになります。
| 防御 | 防げるもの | 設定 |
|---|---|---|
| ハニーポット | すべての入力欄を埋めるボット | 非表示の _gotcha 入力欄 |
| 必須の同意 | メールアドレスだけを送るスクリプト | 定義で required: true にした consent チェックボックス |
| スパムスコア | 使い捨てアドレス(+2)、同一内容の繰り返し(+3)、リンク、スパムフレーズ。5 に達した登録はスパムとして保存されます | なし |
| レート制限 | ループで送信を繰り返す 1 人の訪問者 | デフォルトでオン: Standard、訪問者ごとに 1 分あたり 5 件 |
| 人間確認 | 本物のブラウザーを動かすボット | Spam check を Ikiform に設定すると、キーの管理なしで Cloudflare Turnstile が動きます |
| 許可ドメイン | ほかのサイトにコピーされたフォームのエンドポイント | フォームを置くすべてのドメインを登録します |
スパムにも本物の登録と同じ成功の応答が返り、Webhook は送られません。そのためメールツールには届かず、確認メールが送られることもありません。本物の読者がスパムに入ってしまったら、スパムではないとマークすれば、その時点で Webhook が送られます。各チェックとそのスコアはスパム対策のドキュメントで説明しています。
1 つのフォームを多くのページに
1 つのエンドポイントを、全ページのフッター、各記事の末尾、/newsletter ページに置けます。どの設置場所が登録につながっているかを知るため、場所ごとに別の source の値を付けてください:
footer: 全ページで表示されます。メールアドレスとチェックボックスだけにしましょう。post: 記事を読み終えた読者で、多くの場合もっとも登録につながります。newsletter-page: SNS のプロフィールからのリンク先です。サンプル号を載せる余裕があります。
すべての送信データにはリファラー、つまり読者がいたページそのものも記録されるので、source で設置場所が、リファラーで記事がわかります。フォームを表示する各ドメインを Allowed domains に追加してください。フォームごとに 20 件までで、登録したドメインのサブドメインは自動的に許可されます。
同じ読者が 2 回
Ikiform はメールアドレスで重複する送信をまとめたり拒否したりしません。24 時間以内の同一内容の繰り返しはスパムスコアを上げるだけです。重複はリストの置き場所かエクスポート時に除去してください。ニュースレターではほとんど問題になりません。Kit、MailerLite、beehiiv はいずれもメールアドレスをキーに購読者を管理するので、繰り返しは 2 人目を作らず 1 つの連絡先を更新します。
あとでメールツールを変える
フォームを自分で持つことが報われるのはここです。メールツールを乗り換えるには、通常サイト上のすべての埋め込みを置き換える必要があります。ここではフォームは背後にどのツールがあるかを知りません:
- 埋め込みでは、すべてのページに古いツールへ直接送信するコードがあるので、乗り換えるにはそのすべてを置き換えることになります。
- 自分のフォームなら、すべてのページは Ikiform に送信し続け、Ikiform は同じ Webhook を送り続けます。
- 変わるのは中継の addSubscriber 関数だけで、新しいツールの API を呼ぶようにします。
既存の購読者は、古いツール自身のエクスポートで移してください。誰が確認済みで誰が解除したかを知っているのはそのツールだけです。Ikiform の CSV エクスポートは 1 ファイル 100,000 行までで、数を確認するためのもので、インポート用ではありません。
状況に合わせて選ぶ
上のフォームはどこでも同じです。これらのページでは、確認メール、サーバーのないサイト、3 つのメールツールの具体的な API 呼び出しを扱います。
- ダブルオプトインIkiform は確認メールを送らないので、ダブルオプトインはメールツールで行います。中継が各登録を未確認として追加し、ツールがリンクを送ります。
- 静的サイトHugo、Jekyll、Eleventy、Astro の静的サイトにニュースレター登録フォームを追加し、メールツールの API キーをページに入れず、確認はツールで行います。
- Kit (ConvertKit)購読者を作成し、特典メールのために Kit のフォームに追加し、設置場所のタグを付けるリレーで、自作のニュースレターフォームを Kit につなぎます。
- MailerLite自作の登録フォームを MailerLite につなぎます。リレーが新しい読者をダブルオプトインのために未確認として追加し、購読者に二度確認を求めることはありません。
- beehiiv自作の登録フォームを beehiiv につなぎます。リレーがダブルオプトインをオンにし、設置場所を UTM ソースに、ページをリファラーにして各購読を作成します。
- Next.js のニュースレターフォームNext.js のフッターのニュースレターフォームです。同意のチェックと Turnstile を行うサーバーアクションが Ikiform に送信し、ルートハンドラーが読者を Kit に追加します。
- Astro のニュースレターフォームプリレンダーされたページのままで使える Astro のニュースレターフォームです。Astro Action が同意を確認して Ikiform に送信し、エンドポイントが読者を MailerLite に追加します。
- TanStack Start のニュースレターフォームTanStack Start のニュースレターフォームです。ページ内での登録にはサーバー関数を、JavaScript なしの予備にはサーバールートを使い、本物の登録を beehiiv に送ります。
- React Router のニュースレターフォームReact Router のフッターのニュースレターフォームです。useFetcher が JavaScript なしでも動く /newsletter ルートに送信し、リソースルートが MailerLite に登録を渡します。
- SvelteKit のニュースレターフォームレイアウトに置く SvelteKit 3 のニュースレターフォームです。同意と IP ごとの制限を確認するフォームアクション、ページにとどまる use:enhance、Kit 向けの Webhook のルートを組み合わせます。
- Nuxt のニュースレターフォームJavaScript の有無にかかわらず動く Nuxt のニュースレターフォームです。Turnstile をチェックするサーバールートが Ikiform に送信し、Nitro のルートが beehiiv に登録を渡します。
動作を確認する
フォームを全ページに追加する前に、これを一通り確認してください。Ikiform に無料プランはありません。テストは無料です。プランのないワークスペースでも、localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、500 件で月 $6 からの Pro を選ぶか、買い切りにしてください。10,000 件で $49、または 50,000 件で $149 で、クレジットに有効期限はありません。料金を参照してください。
- フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は
localhostも追加します。空のままにすると、どこからの送信も受け付けます。 - ブラウザーの開発者ツールでチェックボックスを外したまま送信するか、一時的に
requiredを削除します。This box must be checked付きの422が返り、何も保存されません。 - 自分のアドレスで正しく送信します。受信箱を確認するよう促す一文が表示されるか、サンクスページに移動します。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- Webhooks でエンドポイントを開きます。配信は
succeededと表示され、中継の応答が見えます。 - メールツールを開きます。購読者が登録されており、ダブルオプトインがオンなら未確認の状態で、確認メールが受信箱に届いています。
- ハニーポットを埋めて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - 何も届かない場合は、ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信と同じ成功の応答が返ります。
トラブルシューティング
| 問題 | 対処法 |
|---|---|
consent の 422 validation_failed | チェックボックスにチェックが入っていないか、入力欄の name が consent ではありません。サーバー側の実装では true、"true"、"on" のいずれかを送る必要があります。チェックのないボックスは何も送りません。 |
unknown_field の 422 validation_failed | 定義にない入力欄をページが送っています。あとから追加した hidden の source やトラッキング用の入力欄がよくある原因です。定義に追加するか、入力欄を削除してください。 |
403 origin_not_allowed が返る、またはすべての送信がスパムになる | ページのドメインがフォームの Allowed domains にありません。ドメインと、ローカルテスト用の localhost を追加してください。登録したドメインのサブドメインは許可されます。 |
429 rate_limited | 1 人の訪問者がフォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。Retry-After まで待つか、共有の IP 1 つから送信するフォームには Relaxed か Custom を選んでください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |
| 登録は Ikiform に届くのに、メールツールには届かない | Webhooks で配信ログを確認します。400 は中継のシークレットが間違っているという意味です。502 にはメールツールのステータスが入っており、たいていは API キー、リスト ID、フォーム ID の誤りです。 |
| 購読者はメールツールにいるが、確認メールが届かない | ツールでダブルオプトインがオフになっているか、中継が購読者を確認済みとして作成しています。各ツールの設定はダブルオプトインを参照してください。 |
| ブラウザーが自分のページではなく Ikiform のサンクスページに移動する | _redirect は、ドメインが Allowed domains にあり、https: の URL でないと無視されます。ドメインを追加するか、代わりにフォーム設定で Redirect after submit を設定してください。 |
よくある質問
はい。フォームを自分の HTML で書いて Ikiform のようなフォームバックエンドに送信し、各登録を Webhook で Mailchimp やほかのメールツールに API 経由で転送します。フォームはサイトのほかの部分と見た目がそろい、メールツールを変えてもフォームは変わりません。
いいえ。Ikiform は登録を集め、検証して保存し、Webhook で転送します。購読者にメールを送ることはないので、確認メール、毎号の配信、配信停止リンクはメールツールから送られます。
購読者を追加する API があるツールか、Zapier、Make、n8n などの自動化サービスが対応しているツールなら何でも使えます。ワンクリックの連携はなく、接続は中継や自動化が担います。Kit、MailerLite、beehiiv にはこのガイドに手順ごとのページがあります。
中継のサーバー側の環境変数に置き、ページには絶対に入れないでください。メールツールの API キーはたいていリスト全体を読み書きできるので、ブラウザーの JavaScript に入れたキーは誰にでもコピーできるキーになります。
非表示の _gotcha ハニーポットと必須の同意チェックボックスを残し、Allowed domains に自分のドメインを登録して、スパムチェックを Ikiform に設定すれば Cloudflare Turnstile のチェックが入ります。スパムとして保存された登録は Webhook を送らないので、確認メールが送られることもありません。
いいえ。受け付けた送信はすべて保存されます。24 時間以内のまったく同じ再送信はスパムスコアを上げますが、それだけではスパムになりません。リスト、データベース、CSV エクスポートでメールアドレスごとに重複を取り除いてください。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com