ガイド3 分で読めます
自分のサイトで作るイベントの参加登録と出欠フォーム
自分のイベントページで出欠を受け付けます。フォーム、同伴者の上限、正確な人数集計、満員時の締め切り、受付リストまで、チケットアプリなしで。
イベントページに、フォームバックエンドに送信する出欠フォームを置きます。参加・不参加の回答、サーバー側で上限をかけた同伴者数、計画に必要な情報を集めます。人数は、CSV エクスポートか短いスクリプトで、メールアドレスごとの最新の回答から数え、満員になったらフォームを一時停止します。Ikiform はすべての出欠を保存して転送しますが、チケット発行、決済、自動の定員制限はないので、それらが必要ならチケットプラットフォームを使ってください。
ほとんどのイベントにチケットプラットフォームは要りません。ミートアップ、ワークショップ、ローンチパーティー、読書会、無料のカンファレンスに必要なのは 3 つだけです。何がいつあるかを伝えるページ、誰が来るかを集めるフォーム、そして前日に信頼できる人数です。このガイドではそれを自分のサイトに作り、フォームではできない部分もはっきり説明します。
出欠の回答の流れ
フォームはイベントページに置きます。各回答は Ikiform に送信され、Ikiform がフィールドのルールに照らして確認し、スパムを除外し、保存してあなたに知らせます。そこから、計画に使うものへ届きます。ダッシュボード、スプレッドシート、人数を数えるスクリプトなどです。
- ゲストがイベントページで出欠フォームに入力すると、Ikiform のエンドポイントに送信されます。
- Ikiform は同伴者の上限を含むフィールドを確認し、保存する前に送信のスパムスコアを付けます。
- すべての回答がダッシュボードに表示され、絞り込みや CSV でのエクスポートができます。
- 主催者には回答ごとにメールが届き、Reply でそのままゲストに返信できます。
- Ikiform が署名付きの submission.created Webhook を送ります。
- スプレッドシートや短いスクリプトが、各人の最新の回答を使って出欠を人数に集計します。
Ikiform がイベントでやらないこと
選ぶ前にはっきりさせておきましょう。フォームバックエンドは回答を集めるものです。チケットシステムではありません:
- 定員の上限はありません。 一定数の回答やゲストでフォームを締め切る設定はありません。人数を見ながら、自分でフォームを一時停止します。
- チケットも決済もありません。 QR コード、受付アプリ、有料の区分、返金はありません。
- ゲストへのメールはありません。 Ikiform はフォームを入力した本人にはメールを送りません。通知メールが届くのはあなたとチームだけなので、自動返信、確認メール、ダブルオプトインはありません。それらは Webhook をきっかけに、自分のメールツールから送ってください。
- キャンセル待ちはありません。 フォームを一時停止すると、新しい送信は拒否されます。キャンセル待ちが必要なら、代わりに 2 つ目のフォームを置くことになります。
- 重複の除去はありません。 Ikiform は同じメールアドレスからの重複送信を統合も拒否もしません。24 時間以内のまったく同じ再送信は、スパムスコアを上げるだけです。重複はリストを管理する場所か、エクスポート時に取り除いてください。
どれにも、以下で実用的な対処法を示します。いくつかが致命的なら、チケットプラットフォームのほうが適しています。
出欠を受け付ける方法
| 方法 | 得意なこと | トレードオフ |
|---|---|---|
| Eventbrite や Luma などのチケットプラットフォーム | チケット、決済、定員制限、キャンセル待ち、確認メール、受付がすべて揃っている | イベントページと参加者リストは相手のプラットフォームにあり、相手のブランドで表示される。有料チケットには手数料がかかるので、料金ページを確認すること |
| Google Forms | 無料ですぐ作れる。回答の受け付けを止めるスイッチがある | 見た目は Google で、自分のイベントらしくない。ページも別になり、サーバー側の同伴者上限もない |
| カレンダーの招待やグループチャット | 友人 20 人程度なら十分 | 信頼できる人数も、食事の要望も、当日のリストもない |
| 自分のフォームと、Ikiform などのフォームバックエンド | 自分のページ、自分のデザイン、自由なフィールド。同伴者の上限はサーバーで確認され、スパムは除外され、主催者にメールが届き、CSV と Webhook も使える | 人数の集計、登録の締め切り、確認メールの送信は、自分で行うか、メールツールへの Webhook で行う |
有料イベントならチケットプラットフォームを使ってください。ページとリストを自分の手元に置いておきたい無料イベントなら、このガイドの残りがその設定方法です。Eventbrite のページ で、両者をさらに詳しく比較しています。
計画を立てられるフィールド
行動につながることだけを尋ね、それ以外は尋ねないでください。ここにある質問はどれも、注文、予約、手配の内容を変えるものです。試してみてください。プレビューはブラウザーで Ikiform のバリデーターを実行するので、4 人目の同伴者で返る 422 を確認できます。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
| フィールド | 理由 | 注意点 |
|---|---|---|
attending | 不参加も情報です。その人を追いかけずに済みます | yes と no を持つ radio なので、集計は絞り込みで済む |
guests | 料理や椅子を手配する人数 | min: 0、max: 3、integer: true の number。Ikiform はサーバーで 5 や 1.5 を拒否する |
dietary | ケータリングの数 | select ならきれいに合計できる。自由記述ではできない |
access_needs | 段差のない動線、座席、静かな部屋 | 任意。当日までにすべて読んでおく |
email | 変更のお知らせと、変更された回答の照合 | すべての通知メールの reply-to にもなる |
出欠フォーム
スタックを選んでください。どの版も参加と不参加で異なるお礼を表示し、不参加なら同伴者の質問を隠し、来ない人の guests を 0 として送るので、合計がずれません。
フォームをイベントページに貼り付けます。通常の方法で送信して確認ページにリダイレクトするので、JavaScript なしでも動きます。短いスクリプトが、不参加と答えたときに同伴者の質問を隠し、同伴者数を 0 に戻します。
<form id="rsvp" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<fieldset>
<legend>Can you make it?</legend>
<label><input type="radio" name="attending" value="yes" required> Yes</label>
<label><input type="radio" name="attending" value="no"> No</label>
</fieldset>
<div id="if-going">
<label for="guests">Guests you're bringing</label>
<input id="guests" name="guests" type="number" min="0" max="3" step="1" value="0">
<label for="dietary">Dietary needs</label>
<select id="dietary" name="dietary">
<option value="none">None</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten free</option>
<option value="other">Other, tell us below</option>
</select>
<label for="access_needs">Access needs or anything else</label>
<textarea id="access_needs" name="access_needs" rows="3" maxlength="1000"></textarea>
</div>
<input type="hidden" name="_redirect" value="https://example.com/meetup/registered">
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden>
<button type="submit">Send RSVP</button>
</form>
<script>
const rsvp = document.getElementById("rsvp");
const extra = document.getElementById("if-going");
rsvp.addEventListener("change", () => {
const no = rsvp.elements.attending.value === "no";
extra.hidden = no;
if (no) rsvp.elements.guests.value = "0";
});
</script>サーバーアクションがシークレットキーで回答を転送し、ブラウザーが何を送っても不参加なら guests を 0 にします。コンポーネントは回答に応じて、フォームを異なる一文に置き換えます。
"use server";
import { headers } from "next/headers";
export interface RsvpState {
status: "idle" | "going" | "declined" | "error";
message: string;
}
export async function sendRsvp(_previous: RsvpState, formData: FormData): Promise<RsvpState> {
const attending = formData.get("attending") === "no" ? "no" : "yes";
const origin = (await headers()).get("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({
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
attending,
guests: attending === "yes" ? Number(formData.get("guests") || 0) : 0,
dietary: String(formData.get("dietary") ?? "none"),
access_needs: String(formData.get("access_needs") ?? ""),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) {
return attending === "yes"
? { status: "going", message: "You're registered. See you on 14 March at 6:30 pm." }
: { status: "declined", message: "Thanks for letting us know. We'll miss you." };
}
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const [field, problems] = Object.entries(fields)[0] ?? [];
return {
status: "error",
message: field ? `${field}: ${problems?.[0]}` : (result?.error?.message ?? "Couldn't save your RSVP. Try again."),
};
}"use client";
import { useActionState, useState } from "react";
import { type RsvpState, sendRsvp } from "./actions";
const initial: RsvpState = { status: "idle", message: "" };
export function RsvpForm() {
const [state, action, pending] = useActionState(sendRsvp, initial);
const [attending, setAttending] = useState("yes");
if (state.status === "going" || state.status === "declined") return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<fieldset>
<legend>Can you make it?</legend>
<label>
<input type="radio" name="attending" value="yes" defaultChecked onChange={() => setAttending("yes")} /> Yes
</label>
<label>
<input type="radio" name="attending" value="no" onChange={() => setAttending("no")} /> No
</label>
</fieldset>
{attending === "yes" ? (
<>
<label htmlFor="guests">Guests you're bringing</label>
<input id="guests" name="guests" type="number" min={0} max={3} step={1} defaultValue={0} />
<label htmlFor="dietary">Dietary needs</label>
<select id="dietary" name="dietary" defaultValue="none">
<option value="none">None</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten free</option>
<option value="other">Other, tell us below</option>
</select>
<label htmlFor="access_needs">Access needs or anything else</label>
<textarea id="access_needs" name="access_needs" rows={3} maxLength={1000} />
</>
) : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send RSVP"}</button>
</form>
);
}accept: "form" を付けた Astro Action が、何かを転送する前に、同伴者の上限を含めて回答を Zod で確認します。z.coerce.number() はフォームのテキストを数値に変換します。ページはオンデマンドでレンダリングされるので、prerender = false とアダプターはそのままにしてください。
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
rsvp: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1).max(120),
email: z.email(),
attending: z.enum(["yes", "no"]),
guests: z.coerce.number().int().min(0).max(3).default(0),
dietary: z.enum(["none", "vegetarian", "vegan", "gluten_free", "other"]).default("none"),
access_needs: z.string().max(1000).default(""),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const body = { ...input, guests: input.attending === "yes" ? input.guests : 0 };
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(body),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { attending: input.attending };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.rsvp);
const fieldErrors = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">{result.data.attending === "yes" ? "You're registered. See you on 14 March at 6:30 pm." : "Thanks for letting us know. We'll miss you."}</p>
) : (
<form method="POST" action={actions.rsvp}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<fieldset>
<legend>Can you make it?</legend>
<label><input type="radio" name="attending" value="yes" checked /> Yes</label>
<label><input type="radio" name="attending" value="no" /> No</label>
</fieldset>
<label for="guests">Guests you're bringing</label>
<input id="guests" name="guests" type="number" min="0" max="3" step="1" value="0" />
{fieldErrors.guests && <p role="alert">{fieldErrors.guests.join(" ")}</p>}
<label for="dietary">Dietary needs</label>
<select id="dietary" name="dietary">
<option value="none">None</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten free</option>
<option value="other">Other, tell us below</option>
</select>
<label for="access_needs">Access needs or anything else</label>
<textarea id="access_needs" name="access_needs" rows="3" maxlength="1000"></textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send RSVP</button>
</form>
)}POST のサーバー関数が .validator() で同伴者数を確認し、不参加なら 0 にします。コンポーネントは、参加と答えるまで同伴者の質問を隠します。
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const sendRsvp = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const attending = data.get("attending") === "no" ? "no" : "yes";
const guests = attending === "yes" ? Number(data.get("guests") || 0) : 0;
if (!Number.isInteger(guests) || guests < 0 || guests > 3) throw new Error("Bring up to 3 guests.");
return {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
attending,
guests,
dietary: String(data.get("dietary") ?? "none"),
access_needs: String(data.get("access_needs") ?? ""),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
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 { done: false, message: String(result.error.message) };
return { done: true, message: data.attending === "yes" ? "You're registered. See you on 14 March at 6:30 pm." : "Thanks for letting us know. We'll miss you." };
});
export const Route = createFileRoute("/rsvp")({ component: Rsvp });
function Rsvp() {
const send = useServerFn(sendRsvp);
const [attending, setAttending] = useState("yes");
const [state, setState] = useState({ done: false, message: "" });
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setState(await send({ data: new FormData(event.currentTarget) }));
} catch (error) {
setState({ done: false, message: error instanceof Error ? error.message : "Couldn't save your RSVP." });
} finally {
setPending(false);
}
}
if (state.done) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<fieldset>
<legend>Can you make it?</legend>
<label>
<input type="radio" name="attending" value="yes" defaultChecked onChange={() => setAttending("yes")} /> Yes
</label>
<label>
<input type="radio" name="attending" value="no" onChange={() => setAttending("no")} /> No
</label>
</fieldset>
{attending === "yes" ? (
<>
<label htmlFor="guests">Guests you're bringing</label>
<input id="guests" name="guests" type="number" min={0} max={3} step={1} defaultValue={0} />
<label htmlFor="dietary">Dietary needs</label>
<select id="dietary" name="dietary" defaultValue="none">
<option value="none">None</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten free</option>
<option value="other">Other, tell us below</option>
</select>
<label htmlFor="access_needs">Access needs or anything else</label>
<textarea id="access_needs" name="access_needs" rows={3} maxLength={1000} />
</>
) : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send RSVP"}</button>
</form>
);
}ルートの action が回答を転送して結果を返すので、コンポーネントは参加と不参加で異なるお礼を表示できます。data() はエラー時に Ikiform のステータスを保ちます。
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/rsvp";
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const attending = form.get("attending") === "no" ? "no" : "yes";
const origin = request.headers.get("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({
name: String(form.get("name") ?? "").trim(),
email: String(form.get("email") ?? "").trim(),
attending,
guests: attending === "yes" ? Number(form.get("guests") || 0) : 0,
dietary: String(form.get("dietary") ?? "none"),
access_needs: String(form.get("access_needs") ?? ""),
_gotcha: String(form.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { attending, error: "" };
const fields: Record<string, string[]> = result.error.fields ?? {};
const first = Object.values(fields)[0]?.[0];
return data({ attending: null, error: String(first ?? result.error.message) }, { status: response.status });
}
export default function Rsvp({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
if (actionData?.attending === "yes") return <p role="status">You're registered. See you on 14 March at 6:30 pm.</p>;
if (actionData?.attending === "no") return <p role="status">Thanks for letting us know. We'll miss you.</p>;
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<fieldset>
<legend>Can you make it?</legend>
<label><input type="radio" name="attending" value="yes" defaultChecked /> Yes</label>
<label><input type="radio" name="attending" value="no" /> No</label>
</fieldset>
<label htmlFor="guests">Guests you're bringing</label>
<input id="guests" name="guests" type="number" min={0} max={3} step={1} defaultValue={0} />
<label htmlFor="dietary">Dietary needs</label>
<select id="dietary" name="dietary" defaultValue="none">
<option value="none">None</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten free</option>
<option value="other">Other, tell us below</option>
</select>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.error ? <p role="alert">{actionData.error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send RSVP"}</button>
</Form>
);
}デフォルトのフォームアクションが転送前に fail() で同伴者の上限を確認し、エラー時には入力済みの名前とメールアドレスを保持します。bind:group で、参加のときだけ同伴者の質問を表示します。$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 = {
default: async ({ request, fetch, url }) => {
const form = await request.formData();
const attending = form.get("attending") === "no" ? "no" : "yes";
const guests = attending === "yes" ? Number(form.get("guests") || 0) : 0;
const name = String(form.get("name") ?? "").trim();
const email = String(form.get("email") ?? "").trim();
if (!Number.isInteger(guests) || guests < 0 || guests > 3) {
return fail(400, { name, email, message: "You can bring up to 3 guests." });
}
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({
name,
email,
attending,
guests,
dietary: String(form.get("dietary") ?? "none"),
access_needs: String(form.get("access_needs") ?? ""),
_gotcha: String(form.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) return fail(response.status, { name, email, message: String(result.error.message) });
return { attending };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let attending = $state("yes");
</script>
{#if form?.attending === "yes"}
<p role="status">You're registered. See you on 14 March at 6:30 pm.</p>
{:else if form?.attending === "no"}
<p role="status">Thanks for letting us know. We'll miss you.</p>
{:else}
<form method="POST" use:enhance>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.name ?? ""} />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
<fieldset>
<legend>Can you make it?</legend>
<label><input type="radio" name="attending" value="yes" bind:group={attending} /> Yes</label>
<label><input type="radio" name="attending" value="no" bind:group={attending} /> No</label>
</fieldset>
{#if attending === "yes"}
<label for="guests">Guests you're bringing</label>
<input id="guests" name="guests" type="number" min="0" max="3" step="1" value="0" />
<label for="dietary">Dietary needs</label>
<select id="dietary" name="dietary">
<option value="none">None</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten free</option>
<option value="other">Other, tell us below</option>
</select>
{/if}
<label for="access_needs">Access needs or anything else</label>
<textarea id="access_needs" name="access_needs" rows="3" maxlength="1000"></textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}<p role="alert">{form.message}</p>{/if}
<button>Send RSVP</button>
</form>
{/if}サーバールートがシークレットキーを保持します。NUXT_IKIFORM_SECRET_KEY を設定し、nuxt.config.ts の runtimeConfig に ikiformSecretKey を宣言してください。コンポーネントはリアクティブなオブジェクトを $fetch で送ります。
interface RsvpBody {
name?: string;
email?: string;
attending?: string;
guests?: number;
dietary?: string;
_gotcha?: string;
}
export default defineEventHandler(async (event) => {
const body = await readBody<RsvpBody>(event);
const config = useRuntimeConfig(event);
const attending = body.attending === "no" ? "no" : "yes";
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({
name: body.name?.trim() ?? "",
email: body.email?.trim() ?? "",
attending,
guests: attending === "yes" ? Number(body.guests ?? 0) : 0,
dietary: body.dietary ?? "none",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
});
}
return { attending };
});<script setup lang="ts">
const rsvp = reactive({ name: "", email: "", attending: "yes", guests: 0, dietary: "none", _gotcha: "" });
const answer = ref<"yes" | "no" | null>(null);
const error = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
error.value = "";
try {
const result = await $fetch<{ attending: "yes" | "no" }>("/api/rsvp", { method: "POST", body: rsvp });
answer.value = result.attending;
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't save your RSVP.";
}
sending.value = false;
}
</script>
<template>
<p v-if="answer === 'yes'" role="status">You're registered. See you on 14 March at 6:30 pm.</p>
<p v-else-if="answer === 'no'" role="status">Thanks for letting us know. We'll miss you.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="rsvp.name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" v-model="rsvp.email" type="email" autocomplete="email" required />
<fieldset>
<legend>Can you make it?</legend>
<label><input v-model="rsvp.attending" type="radio" value="yes" /> Yes</label>
<label><input v-model="rsvp.attending" type="radio" value="no" /> No</label>
</fieldset>
<template v-if="rsvp.attending === 'yes'">
<label for="guests">Guests you're bringing</label>
<input id="guests" v-model.number="rsvp.guests" type="number" min="0" max="3" step="1" />
<label for="dietary">Dietary needs</label>
<select id="dietary" v-model="rsvp.dietary">
<option value="none">None</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten free</option>
<option value="other">Other</option>
</select>
</template>
<input v-model="rsvp._gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send RSVP" }}</button>
</form>
</template>シングルページアプリはブラウザーから直接送信します。ここには絶対にシークレットキーを入れないでください。フォームは Allowed domains で守ります。不参加なら送信前に guests をリセットし、それ以外は Ikiform の max ルールが捕まえます。
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function RsvpForm() {
const [attending, setAttending] = useState("yes");
const [answer, setAnswer] = useState<"yes" | "no" | null>(null);
const [error, setError] = useState("");
const [sending, setSending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
if (body.get("attending") === "no") body.set("guests", "0");
setSending(true);
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) setAnswer(body.get("attending") === "no" ? "no" : "yes");
else setError(result.error.fields?.guests?.[0] ?? result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
}
setSending(false);
}
if (answer === "yes") return <p role="status">You're registered. See you on 14 March at 6:30 pm.</p>;
if (answer === "no") return <p role="status">Thanks for letting us know. We'll miss you.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<fieldset>
<legend>Can you make it?</legend>
<label>
<input type="radio" name="attending" value="yes" defaultChecked onChange={() => setAttending("yes")} /> Yes
</label>
<label>
<input type="radio" name="attending" value="no" onChange={() => setAttending("no")} /> No
</label>
</fieldset>
<div hidden={attending === "no"}>
<label htmlFor="guests">Guests you're bringing</label>
<input id="guests" name="guests" type="number" min={0} max={3} step={1} defaultValue={0} />
<label htmlFor="dietary">Dietary needs</label>
<select id="dietary" name="dietary" defaultValue="none">
<option value="none">None</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten free</option>
<option value="other">Other</option>
</select>
</div>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send RSVP"}</button>
</form>
);
}同じブラウザー側の送信を Vue の単一ファイルコンポーネントで書いたもので、同伴者の質問は v-show で切り替えます。
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const attending = ref("yes");
const answer = ref<"yes" | "no" | null>(null);
const error = ref("");
const sending = ref(false);
async function send(event: Event) {
const body = new FormData(event.target as HTMLFormElement);
if (attending.value === "no") body.set("guests", "0");
sending.value = true;
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) answer.value = attending.value === "no" ? "no" : "yes";
else error.value = result.error.fields?.guests?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
}
sending.value = false;
}
</script>
<template>
<p v-if="answer === 'yes'" role="status">You're registered. See you on 14 March at 6:30 pm.</p>
<p v-else-if="answer === 'no'" role="status">Thanks for letting us know. We'll miss you.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<fieldset>
<legend>Can you make it?</legend>
<label><input v-model="attending" type="radio" name="attending" value="yes" /> Yes</label>
<label><input v-model="attending" type="radio" name="attending" value="no" /> No</label>
</fieldset>
<div v-show="attending === 'yes'">
<label for="guests">Guests you're bringing</label>
<input id="guests" name="guests" type="number" min="0" max="3" step="1" value="0" />
<label for="dietary">Dietary needs</label>
<select id="dietary" name="dietary">
<option value="none">None</option>
<option value="vegetarian">Vegetarian</option>
<option value="vegan">Vegan</option>
<option value="gluten_free">Gluten free</option>
<option value="other">Other</option>
</select>
</div>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send RSVP" }}</button>
</form>
</template>IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。サーバー版は、ブラウザーの Origin ヘッダーがあればそれも引き継ぎます。サーバー自身の fetch はこれを送らず、Ikiform は無料の localhost テスト送信をこのヘッダーで判定するからです。ドキュメントのフレームワーク別ガイドでさらに詳しく説明しています: Next.js、Astro、SvelteKit、Nuxt、React、Vue、プレーンな HTML。サイトビルダーでは、HTML 版を埋め込みブロックに貼り付けます。
最新の回答から人数を数える
人の気は変わります。月曜に同伴者 2 人で参加と答えた人が、木曜に不参加と答えることもあります。送信データは 2 件で、正しいのは 2 件目です。そこでルールは、メールアドレスごとに最新の回答を 1 つだけ使い、本人の 1 人に同伴者数を足す、というものです。
- 月曜: Ada が同伴者 2 人で参加と回答。すべての送信データを足すと 3 人になります。
- 水曜: Ben が同伴者なしで参加と回答。
- 木曜: Ada がもう一度フォームを送り、不参加と回答。Ikiform はこれを彼女の 2 件目の送信データとして保存します。
- メールアドレスごとに最新の送信データだけを使うと Ada は不参加なので、人数は Ben の 1 人です。
スプレッドシートで
- Submissions で出欠フォームを選び、ステータスを Received、期間を All time にして、Export CSV を選びます。
- Google Sheets にインポートし、
created_atで新しい順に並べ替えます。 - データを選択し、
email列で Data > Data cleanup > Remove duplicates を使います。Sheets は最初に見つけた行を残すので、残るのは最新の行です。 attendingが G 列、guestsが H 列の場合の数え方です。列の文字はエクスポートに合わせて調整してください:
=COUNTIF(G2:G, "yes") + SUMIF(G2:G, "yes", H2:H)エクスポートは先頭に id、created_at、form、status があり、その後にフィールドごとに 1 列が続きます。1 ファイルに最大 100,000 行まで入ります。
スクリプトで
いつでも再計算できる数字がほしいなら、REST API で出欠を読み込みます。一覧は新しい順に返るので、メールアドレスごとに最初に見つかった送信データが最新です。Node とシークレット API キーで実行してください:
import { createClient } from "@ikiform/sdk";
const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY! });
const latest = new Map<string, Record<string, unknown>>();
for await (const submission of ikiform.submissions.iterate({
formId: "frm_xxxxxxxxxxxxxxxx",
status: "received",
limit: 100,
})) {
const email = String(submission.fields.email ?? "").trim().toLowerCase();
if (email && !latest.has(email)) latest.set(email, submission.fields);
}
let people = 0;
const meals = new Map<string, number>();
for (const fields of latest.values()) {
if (fields.attending !== "yes") continue;
people += 1 + Number(fields.guests ?? 0);
const meal = String(fields.dietary ?? "none");
meals.set(meal, (meals.get(meal) ?? 0) + 1);
}
console.log(`${latest.size} people replied. ${people} are coming, guests included.`);
console.table(Object.fromEntries(meals));料理の表は人数ではなく回答の数を数えます。1 つのフォームでは、どの同伴者が何を食べるかまではわからないからです。結婚式のように同伴者の料理が重要なら、1 人ずつ尋ねてください。方法は結婚式の出欠ページ にあります。
満員になったら登録を締め切る
Ikiform は一定数でフォームを止めないので、満員になる前の通知と締め切りの方法の 2 つを用意します。締め切るのはフォームとページの 2 か所です:
- フォームの設定で Pause form を選びます。それ以降の送信は
403 form_pausedで拒否されるので、開きっぱなしのタブやボットが追加することはできません。 - イベントページのフォームを、
Registration is closed. Email us to join the waitlist.のような一文に置き換えます。訪問者にエラーを見せてはいけません。 - 再開するときは Resume form を選び、フォームを元に戻します。
通知には、参加の回答があるたびに再集計し、定員の 90% を超えたら Slack などのチャットの Webhook に投稿する Webhook ハンドラーを使えます。これは Next.js のルートハンドラーですが、同じロジックはどのサーバーでも動きます:
import { createClient } from "@ikiform/sdk";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const CAPACITY = 80;
const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY! });
async function peopleComing(formId: string) {
const seen = new Set<string>();
let people = 0;
for await (const submission of ikiform.submissions.iterate({ formId, status: "received", limit: 100 })) {
const email = String(submission.fields.email ?? "").trim().toLowerCase();
if (!email || seen.has(email)) continue;
seen.add(email);
if (submission.fields.attending === "yes") people += 1 + Number(submission.fields.guests ?? 0);
}
return people;
}
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type !== "submission.created" || event.data.fields.attending !== "yes") {
return new Response(null, { status: 204 });
}
const people = await peopleComing(event.data.form.id);
if (people >= CAPACITY * 0.9) {
await fetch(process.env.ALERT_WEBHOOK_URL!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
text: `${people} of ${CAPACITY} places taken. Pause the RSVP form in Ikiform when you're full.`,
}),
});
}
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}- 毎回 API で再集計するので、変更された回答も反映されます。キーごとに 1 分あたり 100 回の API リクエストができ、1 回の集計で数千件の出欠に対応できます。
- 通知は人に一時停止を促すだけです。一時停止はダッシュボードでの操作なので、知らないうちに締め切られることはありません。
- Webhooks でエンドポイントを追加し、そのシークレットを
IKIFORM_WEBHOOK_SECRETにコピーします。1 つのワークスペースに作れるエンドポイントは 3 つです。リトライについては Webhook のドキュメントを参照してください。
- 受付中は、すべての出欠が Ikiform に送信されます。
- Webhook ハンドラーが参加の回答のたびに最新の回答を再集計し、定員の 90% で通知します。
- 満員かどうかは自分で判断し、Pause form を選びます。新しい送信は 403 form_paused で拒否されます。
- ページのフォームを、締め切りのメッセージとキャンセル待ちへの登録方法に置き換えます。
確認メールなしで参加を確定させる
Ikiform はゲストにメールを送らないので、必要な情報はサンクスページで伝えます。日付、時刻、住所、カレンダーファイルです。ページと同じ場所に置いた静的な .ics ファイルは、どのカレンダーアプリでも使えます。確認ページから Add to calendar としてリンクしてください:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Meetup//RSVP//EN
BEGIN:VEVENT
UID:meetup-2027-03-14@example.com
DTSTAMP:20261025T090000Z
DTSTART:20270314T183000Z
DTEND:20270314T210000Z
SUMMARY:Example meetup
LOCATION:The Old Library, 12 High Street, London
DESCRIPTION:Doors at 6:30 pm. Reply to the organiser if your plans change.
END:VEVENT
END:VCALENDARZ で終わる時刻は UTC なので、カレンダーアプリは各ゲストのタイムゾーンで表示します。本物の確認メールを送りたい場合は、各回答を Webhook でメールツールに直接、または Zapier、Make、n8n 経由で送り、そこから送信してください。同じリストに前日のリマインダーも送れます。
回答の変更とキャンセル
回答の変え方を伝えておきましょう。たとえば Plans changed? Send the form again with the same email. です。集計はすでに最新の回答を使っているので、他に必要なことはありません。通知で変更がその都度わかるので、イベント直前の週には役立ちます。
同じ Wi-Fi を使う 2 人は IP アドレスを共有し、レート制限は IP ごとに数えます。デフォルトの Standard(訪問者ごとに 1 分あたり 5 件)は、イベントページには十分です。重複を防ぐために 1 日 1 件の Custom 制限を設定してはいけません。同じネットワークにいる家族の 2 人目が締め出されますし、重複は最新の回答のルールですでに処理できます。
当日
出欠をエクスポートし、上のように重複を取り除き、attending = yes で絞り込んで名前順に並べ、印刷します。チェック用の列と、到着した同伴者用の列を追加しておきます。会場の電波が悪くても、印刷したリストなら使えます。アクセシビリティの要望の列は受付チームの控えに、食事の列はケータリング業者の控えに残してください。
スパムがリストに入ることはありません。ハニーポット、スパムスコア、レート制限が集計前に除外し、スパムではメールも Webhook も送られません。印刷する前に一度 Spam フィルターを確認し、変わったメッセージの本物の回答が入っていないか見てください。使い捨てアドレスはスパムスコアに 2 を加えます。
状況に合わせて選ぶ
結婚式、無料のコミュニティイベント、ボランティアのシフト表では、それぞれ数え方が違います。何が変わるかは以下のページで説明しています。
- 結婚式の出欠自作の結婚式サイト用の出欠フォーム。招待ごとに 1 回答、名前のあるゲストごとに料理を選び、締め切りと未回答者のリストも用意します。
- Eventbrite なしで無料のコミュニティイベントの参加登録を自分のサイトで運営します。セッションの選択、自分のツールからの確認メール、部屋ごとの人数、受付リストまで。
- ボランティア募集シフトを選べるボランティア募集フォーム。当日の電話番号を集め、人手が足りないシフトを示してシフト表を作るスクリプト付き。
動作を確認する
イベントを告知する 1 週間前に、一連の流れをテストしてください。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースでも localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 で 500 件からの Pro を選ぶか、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを購入します。クレジットに有効期限はありません。料金を参照してください。
- フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は
localhostも追加します。空のままにすると、どこからの送信も受け付けます。 - 別々のメールアドレスで、同伴者 2 人の参加と、不参加を送ります。両方が届き、不参加の
guestsは0になっています。 - 同伴者 3 人、次に 4 人で送ります。4 人目は
Must be at most 3で拒否されます。 - 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに
emailフィールドがあれば Reply で送信者に返信できます。 - 人数集計のスクリプトかスプレッドシートの数式を実行します。送った内容と一致するはずです。
- フォームを一時停止して、もう一度送信します。
403 form_pausedが返ります。再開してください。 - ハニーポットを埋めて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - 何も届かない場合は、ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信と同じ成功の応答が返ります。
トラブルシューティング
| 問題 | 対処法 |
|---|---|
| 人数が多すぎる | すべての送信データを足しています。メールアドレスごとに最新のものだけを残し、不参加で guests が 0 として送られているか確認してください。 |
本番のページで 403 form_paused が返る | ダッシュボードでフォームが一時停止されています。再開するか、ページのフォームを締め切りのメッセージに置き換えてください。 |
guests についての 422 validation_failed | 同伴者がフィールドの max より多いか、小数です。サンプルのように、Ikiform のメッセージをフィールドの横に表示してください。 |
403 origin_not_allowed が返る、またはすべての送信がスパムになる | ページのドメインがフォームの Allowed domains にありません。ドメインと、ローカルテスト用の localhost を追加してください。登録したドメインのサブドメインは許可されます。 |
429 rate_limited | 1 人の訪問者がフォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。Retry-After まで待つか、共有の IP 1 つから送信するフォームには Relaxed か Custom を選んでください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |
| ブラウザーが自分のページではなく Ikiform のサンクスページに移動する | _redirect は、ドメインが Allowed domains にあり、https: の URL でないと無視されます。ドメインを追加するか、代わりにフォーム設定で Redirect after submit を設定してください。 |
よくある質問
自動ではできません。定員の設定はありません。人数を見守るか Webhook から自分に通知し、フォーム設定で Pause form を選んでください。一時停止中のフォームは新しい送信を 403 form_paused で拒否します。
いいえ。Ikiform には決済、チケット、受付の機能はありません。有料イベントにはチケットプラットフォームを、無料イベントには自分のページのフォームを使ってください。
同伴者を、min 0、max、integer を設定した number フィールドにします。Ikiform はこれらのルールをサーバーで適用するので、同伴者が多すぎる送信は、表示できるフィールドエラーで拒否されます。
両方の送信データが保存されます。スプレッドシートで重複を削除するかスクリプトで、メールアドレスごとに最新の送信データだけを数えれば、変更された回答が古い回答に置き換わります。
いいえ。Ikiform がメールを送るのはあなたとチームで、送信者ではないので、自動返信や確認メールはありません。Webhook をメールツールに送り、そこから確認メールを送ってください。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com