# Mailchimp の埋め込みを使わないニュースレター登録フォーム

> 自分のマークアップでニュースレター登録フォームを作り、同意を記録し、ボットの登録を防ぎ、購読者を Kit、MailerLite、beehiiv など任意のツールに渡します。

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

メール配信サービスの埋め込みコードを貼る代わりに、登録フォームを自分の HTML で書き、フォームバックエンドに向けます。バックエンドがアドレスと同意のチェックボックスを検証し、ボットを除外して署名付きの Webhook を送り、小さなリレーがメールツールの API で購読者を追加します。確認メール、ニュースレター、配信停止リンクはメールツールが送るので、あとでツールを変えるときも、変えるのはリレーだけで、すべてのフォームではありません。

どのメールツールにも埋め込みがあります。ページに貼り付ける、生成されたマークアップのかたまりです。動きはしますが、フォームはそのツールのものになります。送信先はそのプロバイダーで、プロバイダーのマークアップや、しばしばスタイルやスクリプトも一緒に入り、別のツールに移る日にはサイト中のコピーを探し回ることになります。このガイドでは、すでに使っているマークアップとフレームワークで自分のものと言える登録フォームを作り、ニュースレターを送るツールにつなぎます。

## 登録の流れ

ここには 2 つの仕事があり、それぞれ置き場所が違います。登録を集めるのはフォームの仕事です。アドレスを検証し、同意のチェックを確認し、ボットを防ぎ、いつ登録されたかを記録します。ニュースレターを送るのはメールの仕事です。アドレスを確認し、各号を送り、配信停止とバウンスを処理します。前者は Ikiform が、後者はメールツールが担います。両者を署名付きの Webhook がつなぎます。

図: ニュースレター登録がページからメールツールに届くまで

1. 読者がページの登録フォームに入力すると、Ikiform のエンドポイントに送信されます。
2. Ikiform はアドレス、必須の同意チェックボックス、ハニーポット、スパムスコア、レート制限をチェックします。
3. 受け付けた登録は、タイムスタンプ、origin、リファラーとともにダッシュボードに保存されます。
4. Ikiform は登録ごとにあなたにメールで知らせることができます。読者にメールを送ることはありません。
5. Ikiform が署名付きの submission.created Webhook を送ります。
6. リレーが署名を検証して読者をメールツールに追加し、メールツールが確認メールを、のちには毎号を送ります。

どのメールツールを使っているかを知っているのはリレーだけです。ツールを変えても、フォーム、スパム除外、保存された記録は変わりません。

## ニュースレター登録を集める 4 つの方法

| 方法 | 得意なこと | トレードオフ |
| --- | --- | --- |
| Mailchimp、Kit、MailerLite、beehiiv などメールツールの埋め込み | 運用するものがありません。確認、タグ付け、ウェルカムメールはツール自身の設定でつながっています。 | マークアップと挙動はツールのものです。Mailchimp によると埋め込みフォームはサイトのスタイルを継承するので、ページ上で見た目が変わることがあり、既存のフォームで reCAPTCHA をオンにするには新しいコードを生成して貼り直す必要があります。どのコピーも 1 つのプロバイダーに縛られます。 |
| ツールの API を呼ぶ自前のサーバールート | フォームとリクエストを完全に制御でき、API キーはサーバーに置いたままにできます。 | 検証、ボット除外、レート制限、同意の記録を自分で作る必要があり、サイトが静的でもサーバーが要ります。 |
| [Typeform](https://ikiform.com/alternatives/typeform) と Zapier のような、ホスト型フォームビルダーと自動化の組み合わせ | コード不要で、自動化ツールはあらゆるメール配信サービスに対応しています。 | ページになじまない iframe と、2 つのサブスクリプション。Webhook やブランディングはプランしだいです。 |
| Ikiform のようなフォームバックエンドとリレー | どのサイトでも自分のマークアップを使え、スパム除外、保存された同意の記録、署名付き Webhook があり、フォーム用のサーバーは不要です。 | Ikiform は読者にメールを送りません。確認メールと毎号のニュースレターは、小さなリレーか自動化を通してメールツールから送られます。 |

サイトが 1 つ、ツールが 1 つで、その埋め込みに満足していますか？ それならそのままで構いません。フォームをデザインに合わせる必要があるとき、複数のサイトにまたがるとき、ツールより長く使うかもしれないときは、自分で作りましょう。

## フォーム

良いニュースレターフォームは小さなものです。メールアドレス、挨拶に使う任意の名前、そしてラベルがそのまま約束になる同意のチェックボックスです。hidden の `source` 入力欄で、サイトのどこで登録されたかを記録します。スタックを選んでください。選択はこのガイドのすべてのコードサンプルに引き継がれます。

**HTML**

フォームをフッター、サイドバー、記事の末尾に貼り付けます。普通の方法で送信して自分のサンクスページに移動するので、JavaScript なしでも動きます。

`footer.html`:

```html
<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` で送信し、フォームを受信箱の確認を促す一文に置き換え、メールアドレスやチェックボックスについてはサーバーのメッセージを表示します。

`newsletter.js`:

```js
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;
});
```

**Next.js**

サーバーアクションがシークレットキーで登録を転送します。エラーはフィールドごとに返るので、メールアドレスのメッセージはメール入力欄の下に、同意のメッセージはチェックボックスの下に表示されます。コンポーネントは `source` プロパティを受け取るので、フッターと記事内のボックスで別の値を記録できます。

`app/newsletter/actions.ts`:

```ts
"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,
  };
}
```

`app/newsletter/newsletter-form.tsx`:

```tsx
"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 でニュースレター登録フォームを作る](https://ikiform.com/ja/guides/newsletter-signup/nextjs.md) を読んでください。

**Astro**

`accept: "form"` を指定した Astro Action は、チェックボックスを boolean として、メールアドレスを `z.email()` で解析します。`isInputError` で、不正なアドレスと拒否された送信を区別できます。フォームの Action にはオンデマンドのページが必要なので、アダプターを追加し、コンポーネントを描画するすべてのページで `export const prerender = false` を設定してください。コンポーネントの中でエクスポートしても効果はありません。

`src/actions/index.ts`:

```ts
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 };
    },
  }),
};
```

`src/components/NewsletterForm.astro`:

```astro
---
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 でニュースレター登録フォームを作る](https://ikiform.com/ja/guides/newsletter-signup/astro.md) を読んでください。

**TanStack Start**

`createServerFn` の POST 関数は `.validator()` で `FormData` を JSON に変え、フィールドのエラーを例外ではなくデータとして返すので、コンポーネントは各メッセージを入力欄の隣に置けます。

`src/components/newsletter-form.tsx`:

```tsx
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 でニュースレター登録フォームを作る](https://ikiform.com/ja/guides/newsletter-signup/tanstack-start.md) を読んでください。

**React Router**

フッターのフォームが、読んでいるページから移動させてはいけません。action を `/newsletter` のリソースルートに置き、`useFetcher` でそこへ送信します。これはナビゲーションなしで送信し、結果を現在のページにとどめます。

`app/routes/newsletter.tsx`:

```ts
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 },
  );
}
```

`app/routes.ts`:

```ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [index("routes/home.tsx"), route("newsletter", "routes/newsletter.tsx")] satisfies RouteConfig;
```

`app/components/newsletter-form.tsx`:

```tsx
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 でニュースレター登録フォームを作る](https://ikiform.com/ja/guides/newsletter-signup/react-router.md) を読んでください。

**SvelteKit**

action はレイアウトではなく必ず `+page.server.ts` に置くので、action は `/newsletter` ページに置き、フッターは `/newsletter?/subscribe` に送信します。独自の `use:enhance` コールバックは `update()` を呼ばずに結果を自分で処理するので、読者は記事にとどまります。`$app/env/private` から読めるよう、`src/env.ts` で `defineEnvVars` を使って `IKIFORM_SECRET_KEY` を宣言してください。

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

```ts
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;
```

`src/lib/NewsletterForm.svelte`:

```svelte
<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 でニュースレター登録フォームを作る](https://ikiform.com/ja/guides/newsletter-signup/sveltekit.md) を読んでください。

**Nuxt**

サーバールートがシークレットキーをブラウザーから隠し、Ikiform のフィールドエラーをエラーの `data` で返します。`NUXT_IKIFORM_SECRET_KEY` を設定し、`nuxt.config.ts` の `runtimeConfig` に `ikiformSecretKey` を宣言してください。

`server/api/newsletter.post.ts`:

```ts
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 };
});
```

`app/components/NewsletterForm.vue`:

```vue
<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 でニュースレター登録フォームを作る](https://ikiform.com/ja/guides/newsletter-signup/nuxt.md) を読んでください。

**React**

シングルページアプリはブラウザーから送信します。`@ikiform/sdk` の `submit()` は `FormData` を送り、常に `ok` と、失敗時にはフィールドのエラーを返して解決するので、`try` ブロックを書く必要はありません。ここには絶対にシークレットキーを入れないでください。代わりに **Allowed domains** がフォームを守ります。

`src/NewsletterForm.tsx`:

```tsx
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**

Vue の単一ファイルコンポーネントで同じ `submit()` を呼びます。フォームは `action` を保持しているので、スクリプトの読み込みに失敗しても送信できます。

`src/components/NewsletterForm.vue`:

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

フレームワークの設定はドキュメントにあります：[Next.js](https://ikiform.com/ja/docs/guides/nextjs.md)、[Astro](https://ikiform.com/ja/docs/guides/astro.md)、[SvelteKit](https://ikiform.com/ja/docs/guides/sveltekit.md)、[Nuxt](https://ikiform.com/ja/docs/guides/nuxt.md)、[React](https://ikiform.com/ja/docs/guides/react.md)、[Vue](https://ikiform.com/ja/docs/guides/vue.md)、[プレーンな HTML](https://ikiform.com/ja/docs/html.md)。[ニュースレターフォームのテンプレート](https://ikiform.com/forms/newsletter-signup)は、同じフィールドを HTML、React、Next.js のサーバーアクションとして生成します。

## フィールドと定義

下の定義をフォームの **Fields** タブに貼り付けると、ページが何を送ってきても Ikiform がサーバー側でそれを適用します。プレビューは同じバリデーターをブラウザーで実行します。チェックを入れずに送信すると、ボットや壊れたページが受け取るエラーを確認できます。

フォーム: Newsletter

- **メールアドレス** (`email`, email, 必須)
- **名** (`first_name`, text)
- **ニュースレターをメールで受け取ります。いつでも配信を停止できます。** (`consent`, checkbox, 必須)
- **登録場所** (`source`, text)

実際のページでは `source` は hidden 入力欄です。ここでは試せるように表示されるテキストフィールドにしています。

| フィールド | 理由 | 注意点 |
| --- | --- | --- |
| `email` | 購読者 | 必須。`type="email"` でスマートフォンに適切なキーボードが出る |
| `first_name` | 各号の挨拶 | 任意。ほとんどのメールツールに対応づけられる名前フィールドがあります |
| `consent` | 本人がメールを希望したという記録 | 必須のチェックボックス。`true` として保存 |
| `source` | サイトのどのフォームで登録されたか：フッター、記事、ポップアップ | hidden。設置場所ごとに別の値を設定し、タグやフィールドとして渡します |

フィールドを定義すると、Ikiform は知らない入力を `unknown_field` で拒否するので、ページに入力欄を追加する前に定義にフィールドを追加してください。`_redirect` のように `_` で始まる名前は Ikiform への指示で、フィールドには数えられません。

## 同意を記録する

ニュースレターは本人が希望したメールです。チェックボックスでその希望を明示し、保存された送信データがそれを記録します。受け付けた登録ごとに Ikiform が保持するのは次の内容です。

`data.fields`:

```json
{
  "email": "ada@example.com",
  "first_name": "Ada",
  "consent": true,
  "source": "footer"
}
```

フィールドのほかに、各送信データには `created_at` のタイムスタンプ、ページの origin とリファラー、国、ブラウザーのユーザーエージェントがあります。これらを合わせると、いつどこでチェックが入れられたかがわかります。この記録をあとで役立てるための習慣が 2 つあります。

1. **ラベルは約束として書きます。** 「ニュースレターをメールで受け取ります。いつでも配信を停止できます。」なら、何が届くかが伝わります。お知らせやオファーも送るなら、ラベルにそう書いてください。
2. **文言を変えたら日付を記録します。** 保存された `true` は本人が同意したことを示しますが、どの文言にかは示しません。ラベルの変更履歴を日付付きで短く残しておけば、どの登録も、そのとき見た文言と照合できます。

Ikiform は記録を保持しますが、あなたや読者が住む地域のルールが何を求めるかは教えられません。詳しい人に確認してください。

## 確認メールはメールツールから送る

Ikiform はフォームに入力した本人にメールを送りません。通知メールはあなたとチームだけに届くので、自動返信、確認メール、ダブルオプトインはありません。それらは [Webhook](https://ikiform.com/ja/docs/webhooks.md) をきっかけにメールツールから送ってください。この分担は意図的なものです。ニュースレターを送るツールは、誰が確認し、誰が配信停止し、どのアドレスがバウンスしたかをすでに把握しています。別のシステムが確認メールを送ると、それらの記録がずれていきます。

そのためリレーは、メールツールが独自の確認メールを送る状態で読者を追加し、読者がクリックするとツールがその人を有効にします。これを有効にする方法はツールごとに違い、フォームの設定、アカウントのトグル、API リクエストのフラグのいずれかです。[ダブルオプトインのガイド](https://ikiform.com/ja/guides/newsletter-signup/double-opt-in.md)では 3 つすべてと、既存の購読者がもう一度登録したときの動作を扱っています。

## リレー

リレーは、Ikiform の Webhook を受け取り、署名をチェックしてメールツールの API を呼ぶ短い関数です。公開された HTTPS の URL と、両側に 1 つずつのシークレットが必要です。これは Next.js のルートハンドラーですが、同じコードは `fetch` がある任意のランタイムで動きます。

`app/api/newsletter/route.ts`:

```ts
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](https://ikiform.com/ja/guides/newsletter-signup/kit.md)、[MailerLite](https://ikiform.com/ja/guides/newsletter-signup/mailerlite.md)、[beehiiv](https://ikiform.com/ja/guides/newsletter-signup/beehiiv.md) の各ページにあります。

リレーの応答によって、Ikiform の次の動作が決まります。

図: メールツールの各応答に対してリレーが Ikiform に返すもの

1. メールツールが購読者を追加または更新した場合：リレーは 204 を返し、配信は succeeded になります。
2. メールツールがレート制限中の場合：リレーは Retry-After 付きで 503 を返し、Ikiform はその時間だけ待ってから次を試みます。
3. メールツールがリクエストを拒否したかダウンしている場合：リレーは 502 を返し、Ikiform は同じ webhook-id でスケジュールどおりにリトライします。

すべての試行はリレーの応答とともにエンドポイントの配信ログに残るので、ツールのステータスを含む 502 を見れば、何を直すべきかがわかります。

ダッシュボードの **Webhooks** にリレーの URL を追加し、ニュースレターのフォームを選んで、`whsec_` のシークレットを `IKIFORM_WEBHOOK_SECRET` にコピーします。ワークスペースには最大 3 つのエンドポイントを置けます。URL はポート 443 の公開 HTTPS である必要があり、`workers.dev` と `pages.dev` のホスト名は拒否されるので、Worker にはカスタムドメインを設定してください。リトライ、ペイロード、署名の方式は [Webhook のドキュメント](https://ikiform.com/ja/docs/webhooks.md)に、各配信を 1 回だけ保存する版は[フォームの送信データを自分のサーバーへ](https://ikiform.com/blog/form-submissions-to-your-own-server.md)にあります。

> コードを書きたくないですか？ リレーの代わりに Webhook を自動化ツールに向け、そのツールのメールツール用アクションを使ってください。[Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)、[Make](https://ikiform.com/blog/form-submissions-to-make.md)、[n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) を参照してください。ワンクリックの連携はありません。どのツールにも、直接、または [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) や [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) などの自動化ツールを通して、署名付きの [Webhook](https://ikiform.com/ja/docs/webhooks.md) で接続します。

## ボットとリスト爆撃を防ぐ

ボットによる登録は、ほとんどのフォーム以上にニュースレターを傷つけます。ボットが登録した実在のアドレスには頼んでもいないメールが届き、その人たちからの苦情は送信元ドメインの評判を下げます。被害者のアドレスを何百ものフォームに一斉に登録して受信箱を埋め尽くすボットもいて、確認メールを送るフォームはすべてその片棒を担ぐことになります。

| 防御 | 防げるもの | 設定 |
| --- | --- | --- |
| ハニーポット | すべての入力欄を埋めるボット | 非表示の `_gotcha` 入力欄 |
| 必須の同意 | メールアドレスだけを送るスクリプト | 定義で `required: true` にした `consent` チェックボックス |
| スパムスコア | 使い捨てアドレス（+2）、同一内容の繰り返し（+3）、リンク、スパムフレーズ。5 に達した登録はスパムとして保存されます | なし |
| レート制限 | ループで送信を繰り返す 1 人の訪問者 | デフォルトでオン: Standard、訪問者ごとに 1 分あたり 5 件 |
| 人間確認 | 本物のブラウザーを動かすボット | **Spam check** を **Ikiform** に設定すると、キーの管理なしで Cloudflare Turnstile が動きます |
| 許可ドメイン | ほかのサイトにコピーされたフォームのエンドポイント | フォームを置くすべてのドメインを登録します |

スパムにも本物の登録と同じ成功の応答が返り、Webhook は送られません。そのためメールツールには届かず、確認メールが送られることもありません。本物の読者がスパムに入ってしまったら、スパムではないとマークすれば、その時点で Webhook が送られます。各チェックとそのスコアは[スパム対策のドキュメント](https://ikiform.com/ja/docs/spam.md)で説明しています。

> 人は 1 回しか購読しないので、ニュースレターのフォームには訪問者ごとの厳しい制限が合っています。読者が 1 つの IP を共有するオフィスやキャンパスから読んでいるなら、Standard ではまとめて数えられます。その場合は訪問者ごとに 1 分あたり 20 件の Relaxed が安全です。[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を参照してください。

## 1 つのフォームを多くのページに

1 つのエンドポイントを、全ページのフッター、各記事の末尾、`/newsletter` ページに置けます。どの設置場所が登録につながっているかを知るため、場所ごとに別の `source` の値を付けてください:

- `footer`: 全ページで表示されます。メールアドレスとチェックボックスだけにしましょう。
- `post`: 記事を読み終えた読者で、多くの場合もっとも登録につながります。
- `newsletter-page`: SNS のプロフィールからのリンク先です。サンプル号を載せる余裕があります。

すべての送信データにはリファラー、つまり読者がいたページそのものも記録されるので、`source` で設置場所が、リファラーで記事がわかります。フォームを表示する各ドメインを **Allowed domains** に追加してください。フォームごとに 20 件までで、登録したドメインのサブドメインは自動的に許可されます。

### 同じ読者が 2 回

Ikiform はメールアドレスで重複する送信をまとめたり拒否したりしません。24 時間以内の同一内容の繰り返しはスパムスコアを上げるだけです。重複はリストの置き場所かエクスポート時に除去してください。ニュースレターではほとんど問題になりません。Kit、MailerLite、beehiiv はいずれもメールアドレスをキーに購読者を管理するので、繰り返しは 2 人目を作らず 1 つの連絡先を更新します。

## あとでメールツールを変える

フォームを自分で持つことが報われるのはここです。メールツールを乗り換えるには、通常サイト上のすべての埋め込みを置き換える必要があります。ここではフォームは背後にどのツールがあるかを知りません:

図: 別のメールツールに乗り換えると何が変わるか

1. 埋め込みでは、すべてのページに古いツールへ直接送信するコードがあるので、乗り換えるにはそのすべてを置き換えることになります。
2. 自分のフォームなら、すべてのページは Ikiform に送信し続け、Ikiform は同じ Webhook を送り続けます。
3. 変わるのは中継の addSubscriber 関数だけで、新しいツールの API を呼ぶようにします。

必要なら 1 週間両方のツールを動かしてください。Webhook のエンドポイント 2 つ、中継 2 つ、フォーム 1 つです。そのあと古いエンドポイントを削除します。

既存の購読者は、古いツール自身のエクスポートで移してください。誰が確認済みで誰が解除したかを知っているのはそのツールだけです。Ikiform の CSV エクスポートは 1 ファイル 100,000 行までで、数を確認するためのもので、インポート用ではありません。

## 状況に合わせて選ぶ

上のフォームはどこでも同じです。これらのページでは、確認メール、サーバーのないサイト、3 つのメールツールの具体的な API 呼び出しを扱います。

- [ダブルオプトイン](https://ikiform.com/ja/guides/newsletter-signup/double-opt-in.md): Ikiform は確認メールを送らないので、ダブルオプトインはメールツールで行います。中継が各登録を未確認として追加し、ツールがリンクを送ります。
- [静的サイト](https://ikiform.com/ja/guides/newsletter-signup/static-site.md): Hugo、Jekyll、Eleventy、Astro の静的サイトにニュースレター登録フォームを追加し、メールツールの API キーをページに入れず、確認はツールで行います。
- [Kit (ConvertKit)](https://ikiform.com/ja/guides/newsletter-signup/kit.md): 購読者を作成し、特典メールのために Kit のフォームに追加し、設置場所のタグを付けるリレーで、自作のニュースレターフォームを Kit につなぎます。
- [MailerLite](https://ikiform.com/ja/guides/newsletter-signup/mailerlite.md): 自作の登録フォームを MailerLite につなぎます。リレーが新しい読者をダブルオプトインのために未確認として追加し、購読者に二度確認を求めることはありません。
- [beehiiv](https://ikiform.com/ja/guides/newsletter-signup/beehiiv.md): 自作の登録フォームを beehiiv につなぎます。リレーがダブルオプトインをオンにし、設置場所を UTM ソースに、ページをリファラーにして各購読を作成します。
- [Next.js のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/nextjs.md): Next.js のフッターのニュースレターフォームです。同意のチェックと Turnstile を行うサーバーアクションが Ikiform に送信し、ルートハンドラーが読者を Kit に追加します。
- [Astro のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/astro.md): プリレンダーされたページのままで使える Astro のニュースレターフォームです。Astro Action が同意を確認して Ikiform に送信し、エンドポイントが読者を MailerLite に追加します。
- [TanStack Start のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/tanstack-start.md): TanStack Start のニュースレターフォームです。ページ内での登録にはサーバー関数を、JavaScript なしの予備にはサーバールートを使い、本物の登録を beehiiv に送ります。
- [React Router のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/react-router.md): React Router のフッターのニュースレターフォームです。useFetcher が JavaScript なしでも動く /newsletter ルートに送信し、リソースルートが MailerLite に登録を渡します。
- [SvelteKit のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/sveltekit.md): レイアウトに置く SvelteKit 3 のニュースレターフォームです。同意と IP ごとの制限を確認するフォームアクション、ページにとどまる use:enhance、Kit 向けの Webhook のルートを組み合わせます。
- [Nuxt のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/nuxt.md): JavaScript の有無にかかわらず動く Nuxt のニュースレターフォームです。Turnstile をチェックするサーバールートが Ikiform に送信し、Nitro のルートが beehiiv に登録を渡します。

## 動作を確認する

フォームを全ページに追加する前に、これを一通り確認してください。Ikiform に無料プランはありません。テストは無料です。プランのないワークスペースでも、`localhost` からのテスト送信を月 50 件まで受け付けます。本番運用するには、500 件で月 $6 からの Pro を選ぶか、買い切りにしてください。10,000 件で $49、または 50,000 件で $149 で、クレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

1. フォームの **Allowed domains** に自分のサイトのドメインを追加し、開発中は `localhost` も追加します。空のままにすると、どこからの送信も受け付けます。
2. ブラウザーの開発者ツールでチェックボックスを外したまま送信するか、一時的に `required` を削除します。`This box must be checked` 付きの `422` が返り、何も保存されません。
3. 自分のアドレスで正しく送信します。受信箱を確認するよう促す一文が表示されるか、サンクスページに移動します。
4. ダッシュボードで **Submissions** を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
5. **Webhooks** でエンドポイントを開きます。配信は `succeeded` と表示され、中継の応答が見えます。
6. メールツールを開きます。購読者が登録されており、ダブルオプトインがオンなら未確認の状態で、確認メールが受信箱に届いています。
7. ハニーポットを埋めて `_gotcha=bot` で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
8. 何も届かない場合は、ダッシュボードのステータスフィルターを **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 の誤りです。 |
| 購読者はメールツールにいるが、確認メールが届かない | ツールでダブルオプトインがオフになっているか、中継が購読者を確認済みとして作成しています。各ツールの設定は[ダブルオプトイン](https://ikiform.com/ja/guides/newsletter-signup/double-opt-in.md)を参照してください。 |
| ブラウザーが自分のページではなく Ikiform のサンクスページに移動する | `_redirect` は、ドメインが **Allowed domains** にあり、`https:` の URL でないと無視されます。ドメインを追加するか、代わりにフォーム設定で **Redirect after submit** を設定してください。 |

## よくある質問

### Mailchimp の埋め込みコードを使わずにニュースレターの登録フォームを作れますか？

はい。フォームを自分の HTML で書いて Ikiform のようなフォームバックエンドに送信し、各登録を Webhook で Mailchimp やほかのメールツールに API 経由で転送します。フォームはサイトのほかの部分と見た目がそろい、メールツールを変えてもフォームは変わりません。

### Ikiform は確認メールやニュースレターを送りますか？

いいえ。Ikiform は登録を集め、検証して保存し、Webhook で転送します。購読者にメールを送ることはないので、確認メール、毎号の配信、配信停止リンクはメールツールから送られます。

### どのメールツールで使えますか？

購読者を追加する API があるツールか、Zapier、Make、n8n などの自動化サービスが対応しているツールなら何でも使えます。ワンクリックの連携はなく、接続は中継や自動化が担います。Kit、MailerLite、beehiiv にはこのガイドに手順ごとのページがあります。

### メールツールの API キーはどこに置けばよいですか？

中継のサーバー側の環境変数に置き、ページには絶対に入れないでください。メールツールの API キーはたいていリスト全体を読み書きできるので、ブラウザーの JavaScript に入れたキーは誰にでもコピーできるキーになります。

### ボットがニュースレターに登録するのをどう防げばよいですか？

非表示の _gotcha ハニーポットと必須の同意チェックボックスを残し、Allowed domains に自分のドメインを登録して、スパムチェックを Ikiform に設定すれば Cloudflare Turnstile のチェックが入ります。スパムとして保存された登録は Webhook を送らないので、確認メールが送られることもありません。

### Ikiform は重複した送信データを削除しますか？

いいえ。受け付けた送信はすべて保存されます。24 時間以内のまったく同じ再送信はスパムスコアを上げますが、それだけではスパムになりません。リスト、データベース、CSV エクスポートでメールアドレスごとに重複を取り除いてください。

### Ikiform は無料ですか？

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

## その他のガイド

- [ダブルオプトイン](https://ikiform.com/ja/guides/newsletter-signup/double-opt-in.md): Ikiform は確認メールを送らないので、ダブルオプトインはメールツールで行います。中継が各登録を未確認として追加し、ツールがリンクを送ります。
- [静的サイト](https://ikiform.com/ja/guides/newsletter-signup/static-site.md): Hugo、Jekyll、Eleventy、Astro の静的サイトにニュースレター登録フォームを追加し、メールツールの API キーをページに入れず、確認はツールで行います。
- [Kit (ConvertKit)](https://ikiform.com/ja/guides/newsletter-signup/kit.md): 購読者を作成し、特典メールのために Kit のフォームに追加し、設置場所のタグを付けるリレーで、自作のニュースレターフォームを Kit につなぎます。
- [MailerLite](https://ikiform.com/ja/guides/newsletter-signup/mailerlite.md): 自作の登録フォームを MailerLite につなぎます。リレーが新しい読者をダブルオプトインのために未確認として追加し、購読者に二度確認を求めることはありません。
- [beehiiv](https://ikiform.com/ja/guides/newsletter-signup/beehiiv.md): 自作の登録フォームを beehiiv につなぎます。リレーがダブルオプトインをオンにし、設置場所を UTM ソースに、ページをリファラーにして各購読を作成します。
- [Next.js のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/nextjs.md): Next.js のフッターのニュースレターフォームです。同意のチェックと Turnstile を行うサーバーアクションが Ikiform に送信し、ルートハンドラーが読者を Kit に追加します。
- [Astro のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/astro.md): プリレンダーされたページのままで使える Astro のニュースレターフォームです。Astro Action が同意を確認して Ikiform に送信し、エンドポイントが読者を MailerLite に追加します。
- [TanStack Start のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/tanstack-start.md): TanStack Start のニュースレターフォームです。ページ内での登録にはサーバー関数を、JavaScript なしの予備にはサーバールートを使い、本物の登録を beehiiv に送ります。
- [React Router のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/react-router.md): React Router のフッターのニュースレターフォームです。useFetcher が JavaScript なしでも動く /newsletter ルートに送信し、リソースルートが MailerLite に登録を渡します。
- [SvelteKit のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/sveltekit.md): レイアウトに置く SvelteKit 3 のニュースレターフォームです。同意と IP ごとの制限を確認するフォームアクション、ページにとどまる use:enhance、Kit 向けの Webhook のルートを組み合わせます。
- [Nuxt のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/nuxt.md): JavaScript の有無にかかわらず動く Nuxt のニュースレターフォームです。Turnstile をチェックするサーバールートが Ikiform に送信し、Nitro のルートが beehiiv に登録を渡します。
- [HTML フォームの送信](https://ikiform.com/ja/guides/html-form-submission.md): フォームが送信されたときにブラウザーが行うこと。どのフィールドを送り、どうエンコードし、サーバーは何を返すべきか、そして Ikiform がどう応答するかを解説します。
- [イベント登録](https://ikiform.com/ja/guides/event-registration.md): 自分のイベントページで出欠を受け付けます。フォーム、同伴者の上限、正確な人数集計、満員時の締め切り、受付リストまで、チケットアプリなしで。
- [Webhook の署名を検証する](https://ikiform.com/ja/guides/webhook-signature-verification.md): フォームの Webhook が本当に Ikiform から来たかを確認する方法。Standard Webhooks の HMAC-SHA256 方式、テストベクター、生のボディ、タイムスタンプ、シークレットのローテーションを解説します。
