가이드11분 분량
웹사이트나 앱에 피드백 폼 추가하기
어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
1~5점 평점, 선택 입력인 코멘트와 이메일로 된 짧은 폼을 만들고 폼 백엔드로 POST하면 이를 위해 서버를 운영할 필요가 없습니다. 브라우저는 전체 URL을 알아서 알려 주지 않으므로 모든 답변에 페이지 주소를 함께 보내세요. 낮은 점수는 당일에 팀 채팅으로 보내고, 추세를 보기 위해 모든 답변을 한곳에 모아 두세요.
대부분의 사람은 사이트의 문제를 말해 주지 않습니다. 그냥 떠납니다. 작은 피드백 폼은 말해 줄 소수의 사람을, 그들이 문제를 알아차린 순간, 문제가 생긴 페이지에서 붙잡습니다. 이 가이드는 어떤 사이트나 앱에도 맞고, 보낸 페이지를 함께 전송하고, 봇을 막고, 불만족스러운 답변을 당일에 팀 앞에 가져다 놓는 폼을 만듭니다.
피드백 폼의 작동 방식
폼은 Ikiform으로 POST합니다. Ikiform은 점수를 검증하고, POST의 스팸 여부를 검사하고, 저장하고, 팀에 이메일을 보내고, 서명된 웹훅을 보냅니다. 웹훅에서 낮은 점수는 채팅 채널로 보내고, 모든 답변은 시트나 데이터베이스로 보낼 수 있습니다.
- 방문자가 점수를 고르고 코멘트를 쓸 수도 있으며, 폼이 페이지 주소와 함께 POST합니다.
- Ikiform이 점수가 1~5의 정수인지 확인하고, 스팸 검사를 실행하고, 답변을 저장합니다.
- 답변이 대시보드에 표시되고, 거기서 필터링하거나 CSV로 내보낼 수 있습니다.
- Ikiform이 팀에 이메일을 보냅니다. 방문자가 이메일을 남겼다면 답장이 바로 그 사람에게 갑니다.
- Ikiform이 필드와 브라우저 메타데이터를 담은 서명된 submission.created 웹훅을 보냅니다.
- 작은 릴레이가 낮은 점수만 팀 채팅에 올리므로 그날 안에 읽힙니다.
피드백을 모으는 방법
도구마다 맞는 일이 다릅니다. 위젯의 모양이 아니라 답변이 도착했을 때 무엇을 봐야 하는지로 고르세요.
| 방식 | 잘하는 것 | 단점 |
|---|---|---|
| Hotjar나 Usersnap 같은 웹사이트 피드백 도구 | script 태그 하나 외에는 코드 없이 스크린샷, 주석, 페이지 맥락을 받을 수 있습니다. | 모든 페이지에 서드파티 스크립트가 들어가고, 답변은 그 제품에 저장되며, 가격은 그쪽 플랜이 정합니다. |
| Sentry User Feedback 같은 에러 트래커의 위젯 | 리포트를 에러와 연결하고, 세션 리플레이를 켜면 무슨 일이 있었는지 녹화본과도 연결합니다. | 의견이 아니라 버그용입니다. 먼저 앱에 트래커의 SDK가 있어야 합니다. |
| Typeform, Tally, Google Forms 같은 폼 빌더 | 코드 없이 다듬어진 폼을 만들 수 있습니다. 링크로 보내는 설문에 좋습니다. | iframe이나 별도 페이지이므로, URL로 넘기지 않으면 답변이 어느 페이지에 대한 것인지 잃어버립니다. |
| Canny 같은 피드백 보드 | 기능 요청에 대한 공개 투표와 상태 업데이트. | 빠른 반응이 아니라 아이디어용입니다. 답변은 설계상 공개됩니다. |
| 직접 만든 API 라우트와 데이터베이스 | 데이터와 모든 로직을 완전히 제어할 수 있습니다. | 스팸 필터링, 레이트 리밋, 알림 이메일, 내보내기를 직접 만들어야 하고 서버가 필요합니다. |
| Ikiform 같은 폼 백엔드 | 어떤 사이트나 앱에서든 직접 작성한 마크업을 씁니다. 서버 없이 검증, 스팸 필터링, 저장, 이메일, 서명된 웹훅을 제공합니다. | 파일 업로드를 지원하지 않으므로 스크린샷은 없고, 투표나 세션 리플레이도 없습니다. |
모든 리포트에 스크린샷이 필요하다면 피드백 도구나 에러 트래커의 스크립트를 쓸 만합니다. 폼이 내 사이트처럼 보이고, 답변을 옮기기 쉽게 두고, 어디로 보낼지 직접 정하고 싶다면 폼 백엔드를 쓰세요. 이 가이드의 나머지는 그 방법을 다룹니다.
폼
질문은 세 개면 충분합니다. 점수, 코멘트, 선택 입력인 이메일입니다. 점수는 사람들이 답하는 부분이고, 코멘트에 가치가 있고, 이메일은 후속 대응을 하는 수단입니다. 아래에서 스택을 고르세요. 선택한 스택은 이 가이드들의 모든 코드 예제에 이어집니다.
마크업 한 블록과 짧은 스크립트입니다. JavaScript가 없어도 폼은 POST하고 리디렉션되며, 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>서버 액션은 자신에게 온 POST의 Referer 헤더를 읽습니다. 폼과 액션이 같은 origin을 공유하므로 브라우저는 전체 페이지 URL을 보내고, 액션은 origin과 경로만 남깁니다. hidden 입력은 필요 없습니다.
"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이 서버에서 hidden 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가 내비게이션 없이 어느 페이지에서든 그 라우트로 POST합니다. app/routes.ts에서 라우트를 api/feedback으로 등록하세요. useLocation이 경로를 제공하고, 액션은 사이트의 origin을 가진 자신의 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 엔드포인트가 POST를 전달하므로 컴포넌트를 레이아웃에 두고 모든 페이지에서 쓸 수 있습니다. $app/state의 page가 현재 URL을 제공합니다. $app/env/private가 읽을 수 있도록 src/env.ts에서 defineEnvVars로 IKIFORM_SECRET_KEY를 선언하세요. SvelteKit 3에서 json() 헬퍼가 deprecated되었으므로 엔드포인트는 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으로 인앱 피드백 폼 만들기을(를) 읽어 보세요.
서버 라우트는 NUXT_IKIFORM_SECRET_KEY에서 채워지는 runtimeConfig.ikiformSecretKey에 키를 보관합니다. 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으로 바로 POST합니다. @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>
);
}같은 브라우저 측 POST를 싱글 파일 컴포넌트로 작성한 것입니다. 점수가 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은 다섯 값의 라디오 그룹입니다. Ikiform이 숫자로 저장하므로 평균을 낼 때 파싱이 필요 없습니다.email은 선택 입력입니다. 사람들은 신원을 밝히지 않아도 될 때 더 솔직하게 쓰고, 답변을 원하는 사람은 이메일을 남깁니다.- hidden
_gotcha입력이 허니팟입니다. 사람에게는 보이지 않고, 모든 입력을 채우는 봇은 스스로 스팸임을 드러냅니다. page는 답변이 어느 페이지에 대한 것인지 기록합니다. 왜 직접 보내야 하는지는 다음 섹션에서 설명합니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.문서의 프레임워크 가이드에서 설정을 더 자세히 다룹니다: Next.js, Astro, SvelteKit, Nuxt, React, Vue, 일반 HTML. 피드백 폼 템플릿은 같은 필드를 HTML, React, 서버 액션으로 생성하고, 버그 리포트와 설문 템플릿은 더 긴 폼을 다룹니다.
필드 정의하기
폼의 Fields 탭에 정의를 붙여 넣으면 Ikiform이 어느 페이지에서 오든 모든 POST에 이를 강제합니다. 아래에서 시험해 보세요. 미리보기는 브라우저에서 Ikiform의 검증기를 실행하므로 점수 0이나 잘못된 이메일을 넣으면 실제로 돌려받을 422가 표시됩니다.
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
rating은 1부터 max(여기서는 5)까지의 정수를 받습니다. 10까지 올릴 수 있지만 선택지가 다섯 개면 더 빨리 답할 수 있습니다.| 필드 | 유형 | 이유 |
|---|---|---|
rating | rating, 최대 5 | 클릭 한 번. 숫자로 저장되므로 페이지별 평균을 낼 수 있습니다 |
comment | textarea, 최대 4,000자 | 점수의 이유 |
email | email, 선택 | 질문한 사람에게 답장합니다. 알림의 reply-to가 됩니다 |
page | url, 선택 | 답변이 어느 페이지에 대한 것인지 |
필드를 정의하면 POST에 담긴 다른 이름은 Unknown field로 거부됩니다. 나중에 앱 버전이나 플랜 이름 같은 맥락을 더 보내려면 먼저 그 필드를 추가하세요. 필드 ID는 소문자 snake_case입니다.
어느 페이지에 대한 것인지 알기
페이지 없는 점수는 대응하기 어렵습니다. Ikiform은 모든 제출에 브라우저 메타데이터를 기록하지만, 그중 하나는 보기보다 쓸모가 적습니다.
| 값 | 출처 | 얻는 것 |
|---|---|---|
meta.user_agent | Ikiform이 기록 | 브라우저 POST에서 얻는 브라우저와 운영체제 |
meta.origin | Ikiform이 기록 | https://example.com 같은 사이트의 origin |
meta.referrer | Ikiform이 기록 | 대개 origin뿐입니다. 브라우저의 기본 정책인 strict-origin-when-cross-origin은 다른 사이트로 POST할 때 경로를 떼어 냅니다 |
meta.country | Ikiform이 기록 | 두 글자 국가 코드 |
page | 폼이 전송 | 전체 경로. 답변이 어느 페이지에 대한 것인지 알 수 있습니다 |
그래서 위의 모든 예제는 page를 직접 보냅니다. HTML과 SPA 버전은 location을 읽고, 서버 버전은 각 프레임워크가 이미 알고 있는 주소를 읽습니다. 모두 쿼리 문자열을 떼어 냅니다. 쿼리 문자열에는 저장하고 싶지 않은 검색어, 추적 ID, 일회용 토큰이 들어 있을 수 있습니다.
meta.user_agent는 방문자의 브라우저가 아니라 서버의 런타임입니다. 페이지 피드백에서는 거의 문제가 되지 않지만 버그 리포트에서는 중요하므로, 버그 리포트 가이드에서는 브라우저의 user agent를 필드로 전달합니다.어디서 물어볼지
폼을 어디에 두느냐에 따라 듣는 내용이 달라집니다. 모든 페이지 푸터에 있는 폼에는 강한 의견이 드문드문 들어옵니다. 작업을 끝낸 직후의 질문에는 꾸준하고 구체적인 답변이 들어옵니다.
| 위치 | 적합한 용도 | 주의할 점 |
|---|---|---|
| 문서 페이지나 글의 끝 | 질문에 답하지 못하는 페이지 찾기 | 페이지당 양이 적습니다. 페이지가 아니라 섹션 단위로 보세요 |
| 앱에서 내보내기나 첫 프로젝트 같은 작업을 마친 뒤 | 특정 흐름이 어떻게 느껴지는지, 기억이 생생할 때 | 작업을 마칠 때마다가 아니라 사람당 한 번만 물어보세요 |
| 모든 화면에 떠 있는 버튼 | 무엇이든, 언제든. React 위젯을 참고하세요 | 내 버튼, 쿠키 배너, 채팅 버블을 가리면 안 됩니다 |
| 이메일 속 링크 | NPS처럼 시간에 따라 추적할 점수 | 이메일 클라이언트는 내 페이지가 아니므로 폼에 사이트의 랜딩 페이지가 필요합니다 |
어디에 두든 폼은 작게 유지하세요. 점수를 먼저 묻고 코멘트 상자는 두 번째 단계가 아니라 점수와 함께 보여 주세요. 두 번째 단계에서 대부분이 이탈하며, 이유 없는 점수라도 받아 둘 가치가 있습니다.
낮은 점수를 사람 앞에 가져다 놓기
- 점수와 보낸 페이지에 상관없이 모든 답변이 저장됩니다.
- Ikiform이 서명된 submission.created 웹훅을 릴레이로 보냅니다.
- 릴레이가 평점을 읽습니다. 3~5점은 당장 더 할 일이 없습니다.
- 1점이나 2점은 페이지와 코멘트와 함께 팀 채팅으로 갑니다.
- 방문자가 이메일을 남겼다면 누군가 당일에 답장합니다.
- 일주일에 한 번 내보내기로 페이지별 평균을 봅니다. 추세는 거기서 드러납니다.
알림 이메일은 폼당 최대 10명의 확인된 수신자에게 가며 모든 답변마다 도착합니다. 하루 몇 건이라면 괜찮습니다. 그 이상이면 이 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의 기본 설정이 대부분을 처리합니다.
| 방어 | 막는 것 | 설정 |
|---|---|---|
| 허니팟 | 모든 입력을 채우는 봇 | 예제의 hidden _gotcha 입력 |
| 스팸 점수 | 링크 3개 이상(+2), 알려진 스팸 문구(+2), 일회용 이메일(+2), 동일한 반복(+3). 5점이 되면 스팸으로 저장됩니다 | 없음 |
| 레이트 리밋 | 반복해서 POST하는 스크립트 | 기본으로 켜짐: Standard, 방문자당 분당 5회, 폼 전체 분당 300회 |
| 허용 도메인 | 다른 사이트에서 온 POST | 폼 설정에 도메인을 추가하세요 |
| 사람 확인 | 실제 브라우저를 구동하는 봇 | Spam check를 Ikiform으로 설정하세요. 그러면 브라우저 fetch POST에 challenge()의 토큰이 필요합니다 |
스팸은 실제 POST와 같은 성공 응답을 받지만 이메일이나 웹훅을 보내지 않고 사용량에도 포함되지 않습니다. 코멘트에 링크 세 개를 붙여 넣은 실제 방문자가 반복 규칙과 합쳐져 링크 규칙에 걸릴 수 있으니 가끔 Spam 필터를 확인하세요. 답변을 스팸 아님으로 표시하면 그때 웹훅이 전송됩니다. 각 검사는 스팸 문서에서 설명합니다.
답변 읽기
처음 몇 주 동안은 모든 코멘트를 읽으세요. 그 후에는 페이지별 평균 점수를 낮은 순으로, 코멘트와 나란히 보는 것이 유용합니다.
내보내기
Submissions를 열고 피드백 폼을 고른 뒤 날짜 범위를 정하고 Export CSV를 누르세요. 열은 id, created_at, form, status 다음에 필드마다 하나씩이므로, 위 필드라면 rating은 E열, page는 H열입니다. 한 번에 최대 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)- 답변이 한두 개뿐인 페이지는 무시하세요. 화난 방문자 한 명은 추세가 아닙니다.
- 점수가 가장 낮은 세 페이지의 코멘트를 읽으세요. 해결책은 대개 처음 몇 개에 있습니다.
- 페이지를 바꾼 뒤에는 전체 기간 수치가 아니라 변경 전후 몇 주의 평균을 비교하세요.
상황별 가이드
위 폼은 어느 페이지에서든 빠른 반응을 받습니다. 아래 페이지들은 다른 목적의 피드백을 다룹니다. 재현할 버그, 따져 볼 요청, 추적할 점수, 떠나려는 고객입니다.
- 버그 리포트 폼재현 단계와 심각도는 사람에게 묻고, URL, 브라우저, 뷰포트, 최근 오류는 페이지가 알아서 추가하는 버그 리포트 폼입니다.
- 기능 요청 폼해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
- React 피드백 위젯작은 패널을 열고, 답변마다 화면, 앱 버전, 계정을 함께 보내고, 작성 중인 내용을 잃지 않는 React 앱용 플로팅 피드백 버튼입니다.
- 베타 테스터 피드백테스터와 빌드별 개인 링크, 두 가지 평점 척도, 빌드마다 나아졌는지 보여 주는 주간 요약을 갖춘 베타 피드백 폼입니다.
- 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 피드백 폼입니다. 리소스 라우트 액션이 쿠키 세션을 읽고, fetcher가 레이아웃에서 POST하며, JS 없는 POST는 원래 페이지로 리디렉션됩니다.
- SvelteKit 피드백 폼SvelteKit 인앱 피드백 폼입니다. 레이아웃의 폼이 하나의 폼 액션으로 POST하고, 액션이 서버에서 locals의 사용자와 요청의 페이지를 추가합니다.
- Nuxt 피드백 폼Nuxt 인앱 피드백 폼입니다. 서버 라우트가 사용자 세션을 읽고 Referer에서 페이지를 가져오며, JavaScript가 로드되지 않았을 때도 동작합니다.
작동 확인하기
폼을 모든 페이지에 두기 전에 아래를 확인하세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 localhost에서 월 50건의 테스트 제출을 받습니다. 실제 운영하려면 월 $6부터 500건을 제공하는 Pro를 고르거나, 한 번 결제로 10,000건 $49 또는 50,000건 $149를 구매하세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
- 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발하는 동안에는
localhost도 추가하세요. 비워 두면 어디서든 POST를 받습니다. - 점수를 고르고 코멘트를 써서 제출하세요. 감사 문구가 폼을 대체하거나, 일반 HTML 버전이라면 브라우저가 리디렉션 페이지로 이동합니다.
- 대시보드에서 Submissions를 여세요. 답변에
rating은 숫자로,page는 전체 경로로 표시됩니다. curl이나 브라우저 콘솔로 점수6을 POST하세요. Ikiform이rating을 지목하는422로 응답합니다.- 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에
email필드가 있으면 Reply는 제출자에게 갑니다. - 점수 1을 제출하세요. 릴레이가 팀 채팅에 올리고, Webhooks의 전달 기록에
succeeded가 표시됩니다. - 허니팟을 채워서
_gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. - 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 Spam으로 바꾸세요. 스팸도 실제 제출과 같은 성공 응답을 받습니다.
문제 해결
| 문제 | 해결 방법 |
|---|---|
403 origin_not_allowed, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 localhost도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
Rating must be at most 5와 함께 422 | 폼이 필드의 max보다 높은 점수를 보냅니다. 라디오 값을 바꾸거나 정의에서 max를 최대 10까지 올리세요. |
Unknown field와 함께 422 | 폼이 정의에 없는 이름을 보냅니다. 새로 추가한 hidden 입력인 경우가 많습니다. 그 ID로 필드를 추가하거나 입력을 제거하세요. |
page가 비어 있음 | 값을 채우는 스크립트가 실행되지 않았거나 폼이 생기기 전에 실행되었습니다. 스크립트를 폼 뒤에 두거나, SPA 예제처럼 제출 시점에 page를 설정하세요. |
meta.referrer에 도메인만 표시됨 | 정상입니다. 브라우저는 기본적으로 크로스 사이트 POST에 origin만 보냅니다. 대신 page 필드를 쓰세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
429 rate_limited | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. Retry-After만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
402 test_limit_reached | 워크스페이스에 플랜이 없고 이번 달 무료 localhost 테스트 50건을 다 썼습니다. 플랜을 고르거나 대시보드의 Send test를 사용하세요. |
질문
폼의 action을 폼 백엔드 엔드포인트로 지정하세요. 폼은 정적 사이트의 일반 HTML이어도 됩니다. 백엔드가 각 답변을 검증, 저장하고 이메일로 보내며, 다른 곳으로 보내고 싶다면 웹훅도 보냅니다.
페이지 주소를 필드로 보내세요. Ikiform이 referrer도 기록하지만 브라우저는 크로스 사이트 POST에 대개 도메인만 보내므로, location에서 채운 page 필드가 확실한 방법입니다.
이메일을 선택 입력으로 만드세요. 이메일이 없으면 사람들이 더 솔직해지고, 답장을 원하는 사람은 남깁니다. 그래도 Ikiform은 각 답변에 브라우저, origin, referrer, 국가를 기록합니다.
파일로는 안 됩니다. Ikiform은 파일이 담긴 POST를 422 files_not_supported로 거부합니다. 대신 스크린샷 링크를 요청하거나, 모든 리포트에 스크린샷이 필요하다면 스크린샷 중심의 피드백 도구를 쓰세요.
rating 필드로는 안 됩니다. rating은 1부터 최대 10까지입니다. 0~10 점수는 min 0, max 10, integer true인 number 필드를 씁니다. 전체 설정은 NPS 가이드에 있습니다.
평점을 확인해 낮은 점수만 Slack이나 Discord에 올리는 작은 릴레이에 웹훅을 연결하세요. 알림 이메일은 모든 답변을 다루고, 릴레이는 오늘 대응해야 할 답변을 다룹니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.