ガイド4 分で読めます
ウェブサイトやアプリにフィードバックフォームを追加する
どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
1〜5 の評価、任意のコメント、任意のメールアドレスだけの短いフォームを作り、フォームバックエンドに送信すれば、そのためにサーバーを動かす必要はありません。ブラウザーは完全な URL を自分からは教えてくれないので、回答ごとにページのアドレスを送ります。低評価はその日のうちにチームのチャットに流し、すべての回答を 1 か所にまとめて傾向をつかみましょう。
サイトの何が悪いのかを伝えてくれる人はほとんどいません。黙って去っていきます。小さなフィードバックフォームがあれば、伝えてくれる数少ない人の声を、気づいたその瞬間に、原因となったページで拾えます。このガイドでは、どんなサイトやアプリにも合い、送信元のページを送り、ボットを締め出し、不満のある回答をその日のうちにチームに届けるフォームを作ります。
フィードバックフォームの仕組み
フォームは Ikiform に送信します。Ikiform はスコアを検証し、スパムをチェックして保存し、チームにメールを送り、署名付きの Webhook を送ります。Webhook から、低評価はチャットのチャンネルへ、すべての回答はシートやデータベースへ送れます。
- 訪問者がスコアを選び、必要ならコメントを書き、フォームがページのアドレスと一緒に送信します。
- Ikiform はスコアが 1〜5 の整数であることを確認し、スパムチェックを行って回答を保存します。
- 回答はダッシュボードに表示され、絞り込みや CSV でのエクスポートができます。
- Ikiform がチームにメールを送ります。訪問者がメールアドレスを残していれば、返信はそのまま本人に届きます。
- Ikiform はフィールドとブラウザーのメタデータを含む、署名付きの submission.created Webhook を送ります。
- 小さなリレーが低評価だけをチームのチャットに投稿するので、その日のうちに読まれます。
フィードバックの集め方
ツールの種類ごとに得意な仕事が違います。ウィジェットの見た目ではなく、回答が届いたときに何を見たいかで選びましょう。
| 方法 | 得意なこと | トレードオフ |
|---|---|---|
| Hotjar や Usersnap などのウェブサイトフィードバックツール | スクリプトタグ 1 つだけで、スクリーンショット、注釈、ページの文脈が取れる。 | 全ページにサードパーティのスクリプトが入り、回答は相手の製品に保存され、料金は相手のプラン次第。 |
| Sentry User Feedback などのエラートラッカーのウィジェット | レポートをエラーに結び付け、セッションリプレイを有効にすれば何が起きたかの録画にも結び付く。 | 意見ではなくバグ向け。先にアプリにトラッカーの SDK を入れる必要がある。 |
| Typeform、Tally、Google Forms などのフォームビルダー | コードなしで洗練されたフォームが作れる。リンクで送るアンケートに向いている。 | iframe か別ページになるので、URL で渡さない限り、回答がどのページについてのものかがわからなくなる。 |
| Canny などのフィードバックボード | 機能リクエストへの公開投票とステータス更新。 | すばやい反応ではなくアイデア向け。回答は仕様上公開される。 |
| 自前の API ルートとデータベース | データとロジックをすべて自分で制御できる。 | スパム対策、レート制限、通知メール、エクスポートを自分で作る必要があり、サーバーも要る。 |
| Ikiform などのフォームバックエンド | どんなサイトやアプリでも自分のマークアップを使える。検証、スパム対策、保存、メール、署名付き Webhook がサーバーなしで手に入る。 | ファイルアップロードに対応していないのでスクリーンショットは不可。投票やセッションリプレイもない。 |
すべてのレポートにスクリーンショットが必要なら、フィードバックツールやエラートラッカーのスクリプトを入れる価値があります。フォームをサイトの見た目に合わせ、回答を持ち運べる形で残し、送り先を自分で決めたいなら、フォームバックエンドを使いましょう。このガイドの残りではその方法を説明します。
フォーム
質問は 3 つで十分です。スコア、コメント、任意のメールアドレスです。スコアは誰もが答えてくれる部分、コメントは価値のある部分、メールアドレスはやり取りを完結させる手段です。下でスタックを選んでください。その選択はこれらのガイドのすべてのコードサンプルに引き継がれます。
マークアップ 1 ブロックと短いスクリプトです。JavaScript がなくてもフォームは送信してリダイレクトし、JavaScript があれば訪問者はページにとどまり、サーバーのエラーをボタンの横で確認できます。スクリプトはクエリ文字列を除いたアドレスを page に入れるので、URL 内のトークンが受信トレイに届くことはありません。
<form id="feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<fieldset>
<legend>How useful was this page?</legend>
<label><input type="radio" name="rating" value="1" required /> 1</label>
<label><input type="radio" name="rating" value="2" /> 2</label>
<label><input type="radio" name="rating" value="3" /> 3</label>
<label><input type="radio" name="rating" value="4" /> 4</label>
<label><input type="radio" name="rating" value="5" /> 5</label>
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="page" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send feedback</button>
<p id="feedback-status" role="status"></p>
</form>
<script>
const form = document.getElementById("feedback");
const status = document.getElementById("feedback-status");
form.elements.page.value = location.origin + location.pathname;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Thanks. The team reads every note." }));
return;
}
const fields = result.error.fields ?? {};
status.textContent = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
} catch {
status.textContent = "Couldn't send it. Try again in a moment.";
}
button.disabled = false;
});
</script>サーバーアクションは自身への送信の Referer ヘッダーを読みます。フォームとアクションは同じオリジンなので、ブラウザーはページの完全な URL を送り、アクションはオリジンとパスだけを残します。非表示の入力欄は要りません。
"use server";
import { headers } from "next/headers";
export interface FeedbackState {
sent: boolean;
error: string;
}
export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
const rating = Number(formData.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return { sent: false, error: "Pick a score from 1 to 5." };
const incoming = await headers();
const referer = incoming.get("referer");
const origin = incoming.get("origin");
const page = referer ? new URL(referer) : null;
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({
rating,
comment: String(formData.get("comment") ?? ""),
email: String(formData.get("email") ?? ""),
page: page ? page.origin + page.pathname : "",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, error: "" };
const fields = result?.error?.fields ?? {};
return { sent: false, error: fields.comment?.[0] ?? fields.email?.[0] ?? "Couldn't send it. Try again." };
}"use client";
import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";
const initial: FeedbackState = { sent: false, error: "" };
export function FeedbackForm() {
const [state, action, pending] = useActionState(sendFeedback, initial);
if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<form action={action}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}Next.js でさらに詳しく知るには Next.js でアプリ内フィードバックフォームを作る を読んでください。
ページはレンダリング中に自分のアドレスを知っているので、Astro.url がサーバー側で非表示の page 入力欄を埋めます。空の入力欄は null としてアクションに届き、Ikiform はそれを未入力として扱います。アクションの結果を表示するには、ページをオンデマンドでレンダリングする必要があります。
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendFeedback: defineAction({
accept: "form",
input: z.object({
rating: z.coerce.number().int().min(1).max(5),
comment: z.string().max(4000).nullish(),
email: z.email().nullish(),
page: z.string().nullish(),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.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 ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { sent: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
const result = Astro.getActionResult(actions.sendFeedback);
const page = Astro.url.origin + Astro.url.pathname;
---
{result?.data?.sent ? (
<p role="status">Thanks. The team reads every note.</p>
) : (
<form method="POST" action={actions.sendFeedback}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label><input type="radio" name="rating" value={score} required /> {score}</label>
))}
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="page" value={page} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Pick a score and check your email address." : result.error.message}</p>
)}
<button>Send feedback</button>
</form>
)}Astro でさらに詳しく知るには Astro でアプリ内フィードバックフォームを作る を読んでください。
POST のサーバー関数が、何かがサーバーの外に出る前にスコアを確認します。コンポーネントは送信時に window.location から page を設定するので、クライアントサイドのナビゲーション後でも正しい値になります。
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const sendFeedback = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const rating = Number(data.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) throw new Error("Pick a score from 1 to 5.");
return {
rating,
comment: String(data.get("comment") ?? ""),
email: String(data.get("email") ?? ""),
page: String(data.get("page") ?? ""),
_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 { sent: true, error: "" };
const fields = result.error.fields ?? {};
return { sent: false, error: String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) };
});
export function FeedbackForm() {
const send = useServerFn(sendFeedback);
const [state, setState] = useState({ sent: false, error: "" });
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
data.set("page", window.location.origin + window.location.pathname);
setPending(true);
try {
setState(await send({ data }));
} catch (error) {
setState({ sent: false, error: error instanceof Error ? error.message : "Couldn't send it. Try again." });
} finally {
setPending(false);
}
}
if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}TanStack Start でさらに詳しく知るには TanStack Start でアプリ内フィードバックフォームを作る を読んでください。
リソースルートがアクションを持ち、useFetcher がナビゲーションなしでどのページからでもそこに送信します。ルートは app/routes.ts に api/feedback として登録します。useLocation がパスを提供し、アクションはそれを自身の URL に対して解決します。その URL にはサイトのオリジンが含まれています。
import { data } from "react-router";
import type { Route } from "./+types/api.feedback";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
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({
rating: String(formData.get("rating") ?? ""),
comment: String(formData.get("comment") ?? ""),
email: String(formData.get("email") ?? ""),
page: new URL(String(formData.get("path") ?? "/"), request.url).href,
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
const fields = result.error.fields ?? {};
return data(
{ sent: false, error: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}import { useFetcher, useLocation } from "react-router";
import type { action } from "../routes/api.feedback";
export function FeedbackForm() {
const fetcher = useFetcher<typeof action>();
const { pathname } = useLocation();
const pending = fetcher.state !== "idle";
if (fetcher.data?.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<fetcher.Form method="post" action="/api/feedback">
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="path" value={pathname} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{fetcher.data?.error ? <p role="alert">{fetcher.data.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</fetcher.Form>
);
}React Router でさらに詳しく知るには React Router でアプリ内フィードバックフォームを作る を読んでください。
+server.ts エンドポイントが送信を転送するので、コンポーネントをレイアウトに置いてすべてのページで動かせます。$app/state の page で現在の URL がわかります。$app/env/private から読めるよう、src/env.ts で defineEnvVars を使って IKIFORM_SECRET_KEY を宣言します。SvelteKit 3 では json() ヘルパーが非推奨なので、エンドポイントは Response.json() を返します。
import { error } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, fetch, url }) => {
const body = await request.json();
const rating = Number(body.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) error(400, "Pick a score from 1 to 5.");
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({
rating,
comment: String(body.comment ?? ""),
email: String(body.email ?? ""),
page: String(body.page ?? ""),
_gotcha: String(body._gotcha ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
error(response.status, String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message));
}
return Response.json({ sent: true });
};<script lang="ts">
import { page } from "$app/state";
let rating = $state(0);
let comment = $state("");
let email = $state("");
let trap = $state("");
let status = $state<"idle" | "sending" | "sent">("idle");
let message = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
status = "sending";
message = "";
const response = await fetch("/api/feedback", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ rating, comment, email, page: page.url.origin + page.url.pathname, _gotcha: trap }),
});
if (response.ok) {
status = "sent";
return;
}
message = (await response.json().catch(() => null))?.message ?? "Couldn't send it. Try again.";
status = "idle";
}
</script>
{#if status === "sent"}
<p role="status">Thanks. The team reads every note.</p>
{:else}
<form onsubmit={send}>
<fieldset>
<legend>How useful was this page?</legend>
{#each [1, 2, 3, 4, 5] as score}
<label><input type="radio" name="rating" value={score} bind:group={rating} required /> {score}</label>
{/each}
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000" bind:value={comment}></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" bind:value={email} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden bind:value={trap} />
{#if message}
<p role="alert">{message}</p>
{/if}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
</form>
{/if}SvelteKit でさらに詳しく知るには SvelteKit でアプリ内フィードバックフォームを作る を読んでください。
サーバールートはキーを runtimeConfig.ikiformSecretKey に保持し、値は NUXT_IKIFORM_SECRET_KEY から読み込まれます。useRoute().path と useRequestURL() で、サーバーでもブラウザーでも同じようにページのアドレスを組み立てます。
export default defineEventHandler(async (event) => {
const body = await readBody<{ rating?: number; comment?: string; email?: string; page?: string; _gotcha?: string }>(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({
rating: body.rating,
comment: body.comment ?? "",
email: body.email ?? "",
page: body.page ?? "",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
throw createError({
status: response.status,
message: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message),
});
}
return { sent: true };
});<script setup lang="ts">
const route = useRoute();
const requestUrl = useRequestURL();
const rating = ref<number | null>(null);
const comment = ref("");
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/feedback", {
method: "POST",
body: {
rating: rating.value,
comment: comment.value,
email: email.value,
page: requestUrl.origin + route.path,
_gotcha: trap.value,
},
});
status.value = "sent";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send it. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
<form v-else @submit.prevent="send">
<fieldset>
<legend>How useful was this page?</legend>
<label v-for="score in 5" :key="score">
<input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
</label>
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" v-model="comment" name="comment" rows="4" maxlength="4000" />
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" v-model="email" name="email" type="email" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
</form>
</template>Nuxt でさらに詳しく知るには Nuxt でアプリ内フィードバックフォームを作る を読んでください。
自前のサーバーがないので、ブラウザーから直接 Ikiform に送信します。@ikiform/sdk の submit() が FormData を送り、{ ok } かフィールドのエラーを返します。ここには絶対にシークレットキーを入れないでください。代わりに Allowed domains(許可ドメイン)がフォームを守ります。
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
export function FeedbackForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
data.set("page", window.location.origin + window.location.pathname);
setStatus("sending");
setError("");
const result = await submit(FORM_ID, data);
if (result.ok) {
setStatus("sent");
return;
}
const fields = result.error.fields ?? {};
setError(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message);
setStatus("idle");
}
if (status === "sent") return <p role="status">Thanks. The team reads every note.</p>;
return (
<form onSubmit={onSubmit}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
</form>
);
}同じブラウザー側の送信を単一ファイルコンポーネントで書いたものです。スコアが 1 か 2 のときはコメントのラベルが変わり、不満のある訪問者からより具体的な回答を引き出します。
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const rating = ref(0);
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
body.set("page", window.location.origin + window.location.pathname);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "sent";
return;
}
const fields = result.error.fields ?? {};
error.value = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't send it. Try again in a moment.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<fieldset>
<legend>How useful was this page?</legend>
<label v-for="score in 5" :key="score">
<input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
</label>
</fieldset>
<label for="comment">{{ rating && rating <= 2 ? "What went wrong?" : "What would make it better?" }}</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000" />
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
</form>
</template>frm_xxxxxxxxxxxxxxxxをダッシュボードにある自分のフォームの ID に置き換えます。ratingは 5 つの値のラジオグループです。Ikiform は数値として保存するので、平均を出すのに変換は要りません。emailは任意です。名乗らなくてよいほうが人は正直に書きますし、返事が欲しい人は自分から残します。- 非表示の
_gotcha入力欄がハニーポットです。人には見えません。すべての入力欄を埋めるボットは自らスパムだと示すことになります。 pageは回答がどのページについてのものかを記録します。自分で送る理由は次のセクションで説明します。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。ドキュメントのフレームワーク別ガイドでセットアップを詳しく説明しています。Next.js、Astro、SvelteKit、Nuxt、React、Vue、プレーン HTML をご覧ください。フィードバックフォームのテンプレートは同じフィールドを HTML、React、サーバーアクションとして生成します。長めのフォームはバグ報告とアンケートのテンプレートをどうぞ。
フィールドを定義する
フォームの Fields タブに定義を貼り付けると、Ikiform はどのページからの送信にもそれを適用します。下で試してみてください。プレビューはブラウザー内で Ikiform のバリデーターを実行するので、スコア 0 や不正なメールアドレスでは、実際に返ってくる 422 が表示されます。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
rating は 1 から max(ここでは 5)までの整数を受け付けます。10 まで上げられますが、選択肢は 5 つのほうがすばやく答えられます。| フィールド | 種類 | 理由 |
|---|---|---|
rating | rating、最大 5 | ワンクリック。数値で保存されるのでページごとに平均を出せる |
comment | textarea、最大 4,000 文字 | スコアの理由 |
email | email、任意 | 質問した人に返信できる。通知の reply-to になる |
page | url、任意 | 回答がどのページについてのものか |
フィールドを定義すると、送信に含まれるそれ以外の名前は Unknown field として拒否されます。後からアプリのバージョンやプラン名などの文脈も送りたくなったら、先にそのフィールドを追加してください。フィールド ID は小文字の snake_case です。
どのページについての回答かを把握する
ページのわからないスコアでは手の打ちようがありません。Ikiform は送信データごとにブラウザーのメタデータを記録しますが、そのうちの 1 つは見た目ほど役に立ちません。
| 値 | 取得元 | 得られるもの |
|---|---|---|
meta.user_agent | Ikiform が記録 | ブラウザーからの送信なら、ブラウザーと OS |
meta.origin | Ikiform が記録 | サイトのオリジン(https://example.com など) |
meta.referrer | Ikiform が記録 | たいていはオリジンだけ。ブラウザーの既定は strict-origin-when-cross-origin ポリシーで、別サイトへの送信ではパスが落とされる |
meta.country | Ikiform が記録 | 2 文字の国コード |
page | フォームが送信 | 完全なパス。回答がどのページについてのものかがわかる |
だから上のサンプルはすべて page を自分で送っています。HTML 版と SPA 版は location を読み、サーバー版は各フレームワークがすでに知っているアドレスを読みます。どれもクエリ文字列を落とします。そこには保存したくない検索語、トラッキング ID、ワンタイムトークンが入っていることがあるからです。
meta.user_agent は訪問者のブラウザーではなくサーバーのランタイムになります。ページへのフィードバックならほとんど問題になりませんが、バグ報告では問題になります。バグ報告のガイドでは、ブラウザーのユーザーエージェントをフィールドとして転送しています。どこで尋ねるか
フォームを置く場所で、届く声が変わります。全ページのフッターに置いたフォームには、強い意見がぽつぽつと届きます。タスクの終わりに置いた質問には、具体的な回答が安定して届きます。
| 設置場所 | 向いていること | 注意点 |
|---|---|---|
| ドキュメントページや記事の末尾 | 疑問に答えられていないページを見つける | ページごとの件数は少ない。ページ単位ではなくセクション単位で読む |
| エクスポートや最初のプロジェクトなど、アプリでのタスクの後 | 特定のフローの使い心地を、記憶が新しいうちに | タスクを終えるたびではなく、1 人につき 1 回だけ尋ねる |
| 全画面に浮かぶボタン | 何でも、いつでも。React ウィジェットを参照 | 自分のボタン、Cookie バナー、チャットの吹き出しを覆ってはいけない |
| メール内のリンク | NPS のように経時的に追いたいスコア | メールクライアントは自分のページではないので、フォームにはサイト上のランディングページが必要 |
どこに置くにしても、フォームは小さく保ちましょう。最初にスコアを尋ね、コメント欄は 2 ステップ目ではなく一緒に表示します。2 ステップ目でほとんどの人が離脱しますし、理由のないスコアでも十分価値があります。
低評価を人の目に届ける
- スコアやページに関係なく、すべての回答が保存されます。
- Ikiform が署名付きの submission.created Webhook をリレーに送ります。
- リレーが評価を読みます。スコア 3〜5 なら、すぐに何かする必要はありません。
- スコア 1 か 2 は、ページとコメントと一緒にチームのチャットに送られます。
- 訪問者がメールアドレスを残していれば、誰かがその日のうちに返信します。
- 週に 1 回、エクスポートでページごとの平均を確認します。傾向はそこに表れます。
通知メールはフォームごとに確認済みの受信者最大 10 人に届き、回答があるたびに送られます。1 日に数件なら問題ありません。それを超えたら、この Next.js ルートのようなリレーで低評価をチャットに送りましょう。同じコードは Cloudflare Worker でも動きます。
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
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") return new Response(null, { status: 204 });
const { rating, comment, page, email } = event.data.fields;
if (Number(rating) > 2) return new Response(null, { status: 204 });
const lines = [
`*${rating}/5* on ${page ?? event.data.meta.origin ?? "an unknown page"}`,
comment ? `> ${String(comment).slice(0, 500)}` : "_No comment._",
email ? `Reply to ${email}` : "Anonymous",
];
const response = await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: lines.join("\n") }),
});
if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}verifyWebhookが署名とタイムスタンプをチェックするので、他人がこの URL を使ってチャンネルに投稿することはできません。- スコアが 2 より大きければ
204を返して終わります。Ikiform は 2xx ならどれでも配信済みとみなします。 - Slack の呼び出しが失敗すると
502を返し、Ikiform が同じwebhook-idでリトライするので、短い障害で通知が失われることはありません。 - フィールドの値は訪問者が入力したものです。リレーはチャットに届く前にコメントを 500 文字に切り詰めます。
Webhooks でエンドポイントを追加し、その whsec_ シークレットを IKIFORM_WEBHOOK_SECRET にコピーします。ワークスペースごとにエンドポイントは 3 つまでです。ノーコードでつなぐ方法は Slack、Discord、Google Sheets を参照してください。チケットにすべきフィードバックなら、Linear と GitHub Issues に送信データごとにチケットを作るリレーがあります。
ボットを締め出す
全ページのフィードバックフォームは、全ページにある公開フォームです。ボットはそれを見つけ、送ってくるものの大半はリンクです。大部分は Ikiform の既定の設定で対処できます。
| 防御 | 防げるもの | 設定 |
|---|---|---|
| ハニーポット | すべての入力欄を埋めるボット | サンプルにある非表示の _gotcha 入力欄 |
| スパムスコア | リンク 3 つ以上(+2)、既知のスパムフレーズ(+2)、使い捨てメール(+2)、同一内容の繰り返し(+3)。5 に達するとスパムとして保存される | なし |
| レート制限 | ループで送信するスクリプト | デフォルトで有効: Standard で、訪問者ごとに 1 分あたり 5 件、フォーム全体で 1 分あたり 300 件 |
| 許可ドメイン | 他のサイトからの送信 | フォームの設定に自分のドメインを追加する |
| 人間確認 | 本物のブラウザーを動かすボット | Spam check を Ikiform に設定します。ブラウザーの fetch による送信には challenge() のトークンが必要になります |
スパムにも本物の送信と同じ成功の応答を返し、メールも Webhook も送らず、使用量にも数えません。コメントにリンクを 3 つ貼った本物の訪問者が、同じ内容の繰り返しと重なってリンクのルールに引っかかることがあるので、ときどき Spam フィルターを確認してください。回答をスパムではないとマークすると、その時点で Webhook が送られます。各チェックはスパムのドキュメントで説明しています。
回答を読む
最初の数週間はすべてのコメントを読んでください。その後に役立つのは、ページごとの平均スコアを低い順に並べ、横にコメントを置いた表示です。
エクスポート
Submissions を開き、フィードバックフォームを選んで期間を指定し、Export CSV を選びます。列は id、created_at、form、status のあとにフィールドごとに 1 列ずつ並ぶので、上のフィールドなら rating は E 列、page は H 列です。1 回のエクスポートは最大 100,000 行です。それ以上はエクスポートガイドで API を説明しています。
ファイルを Google Sheets にインポートし、空のシートに次の式を入れます。各ページの平均スコアと回答数を、悪い順に一覧にします。
=QUERY(A2:H, "select H, avg(E), count(E) where D = 'received' and E is not null group by H order by avg(E) asc label avg(E) 'average', count(E) 'answers'", 0)- 回答が 1、2 件しかないページは無視してください。怒った訪問者が 1 人いても傾向とはいえません。
- スコアが最も低い 3 ページのコメントを読みます。直すべき点はたいてい最初の数件に書かれています。
- ページを変更したら、通算の数字ではなく、変更前と変更後の数週間の平均を比べます。
状況に合わせて選ぶ
上のフォームは、どのページでも使える手軽な反応の収集に対応しています。次のページでは、別の目的を持つフィードバックを扱います。再現すべきバグ、検討すべき要望、追跡すべきスコア、離れていく顧客です。
- バグ報告フォーム手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。
- 機能要望フォーム解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
- React フィードバックウィジェットReact アプリ用のフローティングのフィードバックボタン。小さなパネルを開き、回答ごとに画面、アプリのバージョン、アカウントを送り、下書きを失いません。
- ベータテスターのフィードバックテスターとビルドごとの個別リンク、2 つの評価スケール、各ビルドが良くなったかがわかる週次サマリーを備えたベータ版フィードバックフォームです。
- NPS アンケートメールに 0〜10 のスコアを載せ、スコアに応じて変わる追加質問を自分のページで尋ね、スクリプトで NPS を計算する NPS アンケートを実施します。
- 解約アンケートSaaS の解約フローで解約理由を尋ね、スキップはワンクリックのまま、理由を Stripe と Ikiform に保存し、解約を決して遅らせない方法。
- Next.js のフィードバックフォームNext.js のアプリ内フィードバックフォーム。サーバーアクションがセッションを読み、リクエストからページを取り、ユーザー情報を付けて評価を転送します。
- Astro のフィードバックフォームAstro のアプリ内フィードバックフォーム。ユーザーはミドルウェアの locals から、ページはリクエストから、画面はルートのパターンから取得するアクションを作ります。
- TanStack Start のフィードバックフォームTanStack Start のアプリ内フィードバックフォーム。セッションのミドルウェア付きの POST サーバー関数、文脈としてのルート ID、リクエストヘッダーから読むページを組み合わせます。
- React Router のフィードバックフォームReact Router 8 のフィードバックフォーム。リソースルートの action が Cookie セッションを読み、レイアウトの fetcher が送信し、JS なしの送信は元のページにリダイレクトします。
- SvelteKit のフィードバックフォームSvelteKit のアプリ内フィードバックフォーム。レイアウトのフォームが 1 つのフォームアクションに送信し、アクションがサーバー側で locals のユーザーとリクエストのページを追加します。
- Nuxt のフィードバックフォームNuxt のアプリ内フィードバックフォーム。ユーザーのセッションを読み、Referer からページを取得し、JavaScript の読み込み前でも動くサーバールートを使います。
動作を確認する
すべてのページにフォームを置く前に、ひととおり確認してください。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースでも localhost からのテスト送信を月 50 件まで受け付けます。本番運用には、月 $6 からで 500 件の Pro を選ぶか、1 回払いで 10,000 件 $49 または 50,000 件 $149 を選びます。クレジットに期限はありません。料金を参照してください。
- フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は
localhostも追加します。空のままにすると、どこからの送信も受け付けます。 - スコアを選び、コメントを書いて送信します。フォームがお礼の一文に置き換わるか、プレーンな HTML 版ではブラウザーがリダイレクト先のページに移動します。
- ダッシュボードで Submissions を開きます。回答には
ratingが数値で、pageがフルパスで表示されます。 curlかブラウザーのコンソールでスコア6を送信します。Ikiform はratingを示して422を返します。- 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに
emailフィールドがあれば Reply で送信者に返信できます。 - スコア 1 を送信します。リレーがチームのチャットに投稿し、Webhooks の配信に
succeededと表示されます。 - ハニーポットを埋めて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - 何も届かない場合は、ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信と同じ成功の応答が返ります。
トラブルシューティング
| 問題 | 対処法 |
|---|---|
403 origin_not_allowed が返る、またはすべての送信がスパムになる | ページのドメインがフォームの Allowed domains にありません。ドメインと、ローカルテスト用の localhost を追加してください。登録したドメインのサブドメインは許可されます。 |
Rating must be at most 5 付きの 422 | フォームがフィールドの max より高いスコアを送っています。ラジオボタンの値を変えるか、定義の max を最大 10 まで上げてください。 |
Unknown field の 422 | フォームが定義にない名前を送っています。新しく追加した hidden 入力欄であることが多いです。その id のフィールドを追加するか、入力欄を削除してください。 |
page が空 | 値を入れるスクリプトが実行されていないか、フォームができる前に実行されています。スクリプトをフォームの後ろに置くか、SPA のサンプルのように送信時に page を設定してください。 |
meta.referrer に自分のドメインしか表示されない | 想定どおりです。ブラウザーはクロスサイトの送信では、デフォルトで origin しか送りません。代わりに page フィールドを使ってください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |
429 rate_limited | 1 人の訪問者がフォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。Retry-After まで待つか、共有の IP 1 つから送信するフォームには Relaxed か Custom を選んでください。 |
402 test_limit_reached | ワークスペースにプランがなく、今月の無料の localhost テスト 50 件を使い切っています。プランを選ぶか、ダッシュボードの Send test を使ってください。 |
よくある質問
フォームの action をフォームバックエンドのエンドポイントに向けます。フォームは静的サイト上のプレーンな HTML で構いません。バックエンドが各回答を検証・保存してメールで届け、ほかの場所に送りたければ Webhook も送ります。
ページのアドレスをフィールドとして送ります。Ikiform はリファラーも記録しますが、クロスサイトの送信ではブラウザーはたいていドメインしか送らないので、location から設定する page フィールドが確実な方法です。
メールアドレスは任意にしてください。ないほうが人は率直に書きますし、返事が欲しい人は自分で残します。それでも Ikiform は各回答にブラウザー、origin、リファラー、国を記録します。
ファイルとしてはできません。Ikiform はファイル付きの送信を 422 files_not_supported で拒否します。代わりにスクリーンショットへのリンクを尋ねるか、すべての報告にスクリーンショットが必要なら、スクリーンショット中心のフィードバックツールを使ってください。
rating フィールドではできません。こちらは 1 から最大 10 までです。0〜10 のスコアには、min 0、max 10、integer true の number フィールドを使います。完全な設定は NPS のガイドにあります。
評価を確認して低いスコアだけを Slack や Discord に投稿する、小さなリレーへの Webhook を追加します。通知メールはすべての回答を対象にし、リレーは今日中に対応が必要なものを対象にします。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com