Panduan14 menit baca
Tambahkan formulir feedback ke website atau aplikasi Anda
Pasang formulir feedback di halaman mana pun atau di dalam aplikasi Anda: rating, komentar, halaman asalnya, filter spam, dan peringatan saat skornya rendah.
Tambahkan formulir singkat berisi rating 1 sampai 5, komentar opsional, dan email opsional, lalu kirim ke backend formulir agar Anda tidak perlu menjalankan server untuknya. Kirim alamat halaman bersama setiap jawaban, karena browser tidak akan memberi tahu URL lengkapnya dengan sendirinya. Teruskan skor rendah ke chat tim di hari yang sama dan simpan setiap jawaban di satu tempat untuk melihat tren.
Kebanyakan orang tidak pernah memberi tahu apa yang salah dengan situs Anda. Mereka langsung pergi. Formulir feedback kecil menangkap segelintir orang yang mau bicara, tepat saat mereka menyadarinya, di halaman yang menjadi penyebabnya. Panduan ini membuat formulir yang cocok untuk situs atau aplikasi apa pun, mengirim halaman asalnya, menahan bot, dan menyampaikan jawaban yang kecewa ke tim Anda di hari yang sama.
Cara kerja formulir feedback
Formulir mengirim ke Ikiform. Ikiform memvalidasi skor, memeriksa kiriman dari spam, menyimpannya, mengirim email ke tim Anda, dan mengirim webhook bertanda tangan. Dari webhook, skor rendah masuk ke channel chat dan setiap jawaban bisa masuk ke sheet atau database.
- Pengunjung memilih skor, mungkin menulis komentar, lalu formulir mengirimnya bersama alamat halaman.
- Ikiform memeriksa bahwa skornya bilangan bulat dari 1 sampai 5, menjalankan pemeriksaan spam, dan menyimpan jawabannya.
- Jawaban muncul di dashboard, tempat Anda bisa memfilternya dan mengekspornya sebagai CSV.
- Ikiform mengirim email ke tim Anda. Jika pengunjung meninggalkan email, tombol Balas langsung menuju ke mereka.
- Ikiform mengirim webhook submission.created bertanda tangan berisi field dan metadata browser.
- Relay kecil hanya meneruskan skor rendah ke chat tim Anda, agar dibaca hari itu juga.
Cara mengumpulkan feedback
Setiap jenis alat dibuat untuk tugas yang berbeda. Pilih berdasarkan apa yang perlu Anda lihat saat jawaban masuk, bukan dari tampilan widgetnya.
| Pendekatan | Unggul dalam | Kekurangan |
|---|---|---|
| Alat feedback website, seperti Hotjar atau Usersnap | Screenshot, anotasi, dan konteks halaman tanpa kode selain satu tag script. | Script pihak ketiga di setiap halaman, jawaban disimpan di produk mereka, dan harga ditentukan oleh paket mereka. |
| Widget dari error tracker, seperti Sentry User Feedback | Mengaitkan laporan dengan error-nya dan, jika session replay aktif, dengan rekaman kejadiannya. | Dibuat untuk bug, bukan opini. Anda perlu SDK tracker-nya di aplikasi Anda terlebih dahulu. |
| Form builder, seperti Typeform, Tally, atau Google Forms | Formulir rapi tanpa kode. Cocok untuk survei yang Anda kirim lewat tautan. | Iframe atau halaman terpisah, sehingga jawaban kehilangan halaman yang dibahas kecuali Anda menyertakannya di URL. |
| Papan feedback, seperti Canny | Voting publik dan pembaruan status untuk permintaan fitur. | Dibuat untuk ide, bukan reaksi cepat. Jawabannya memang publik. |
| Route API dan database Anda sendiri | Kendali penuh atas data dan logika apa pun. | Anda membangun sendiri filter spam, rate limit, email notifikasi, dan ekspor, dan Anda butuh server. |
| Form backend, seperti Ikiform | Markup Anda sendiri di situs atau aplikasi apa pun. Validasi, filter spam, penyimpanan, email, dan webhook bertanda tangan tanpa server. | Tanpa screenshot, karena unggah file tidak didukung, dan tanpa voting atau session replay. |
Jika Anda butuh screenshot di setiap laporan, alat feedback atau error tracker sepadan dengan script-nya. Jika Anda ingin formulir tampil seperti situs Anda, jawaban tetap portabel, dan Anda sendiri yang menentukan ke mana jawaban dikirim, gunakan backend formulir. Sisa panduan ini melakukan hal itu.
Formulirnya
Tiga pertanyaan sudah cukup: skor, komentar, dan email opsional. Skor adalah yang paling sering dijawab orang; komentar adalah tempat nilainya berada; email adalah cara Anda menindaklanjutinya. Pilih stack Anda di bawah; pilihan itu berlaku untuk setiap contoh kode di panduan ini.
Satu blok markup dan script pendek. Tanpa JavaScript formulir tetap mengirim dan me-redirect; dengan JavaScript, pengunjung tetap di halaman dan melihat error dari server di sebelah tombol. Script mengisi page dengan alamat tanpa query string, sehingga token di URL tidak pernah sampai ke inbox Anda.
<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>Server action membaca header Referer dari kirimannya sendiri. Formulir dan action berada di origin yang sama, jadi browser mengirim URL halaman lengkap, dan action hanya menyimpan origin dan path-nya. Tidak perlu input tersembunyi.
"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>
);
}Ingin lebih jauh dengan Next.js? Baca Buat formulir feedback dalam aplikasi di Next.js.
Halaman mengetahui alamatnya sendiri saat dirender, jadi Astro.url mengisi input tersembunyi page di server. Input kosong sampai ke action sebagai null, yang dianggap Ikiform sebagai tidak ada. Halaman harus dirender on demand agar hasil action bisa tampil.
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>
)}Ingin lebih jauh dengan Astro? Baca Buat formulir feedback dalam aplikasi di Astro.
Server function POST memeriksa skor sebelum apa pun keluar dari server Anda. Komponen mengatur page dari window.location saat submit, jadi nilainya tetap benar bahkan setelah navigasi di sisi klien.
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>
);
}Ingin lebih jauh dengan TanStack Start? Baca Buat formulir feedback dalam aplikasi di TanStack Start.
Resource route memegang action, dan useFetcher mengirim ke sana dari halaman mana pun tanpa navigasi. Daftarkan route sebagai api/feedback di app/routes.ts. useLocation menyediakan path-nya, dan action me-resolve path itu terhadap URL-nya sendiri, yang memiliki origin situs Anda.
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>
);
}Ingin lebih jauh dengan React Router? Baca Buat formulir feedback dalam aplikasi dengan React Router.
Endpoint +server.ts meneruskan kiriman, sehingga komponen bisa ditaruh di layout dan bekerja di setiap halaman. page dari $app/state memberikan URL saat ini. Deklarasikan IKIFORM_SECRET_KEY di src/env.ts dengan defineEnvVars agar $app/env/private bisa membacanya. Endpoint mengembalikan Response.json(), karena SvelteKit 3 menghentikan helper 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}Ingin lebih jauh dengan SvelteKit? Baca Buat formulir feedback dalam aplikasi di SvelteKit.
Server route menyimpan key di runtimeConfig.ikiformSecretKey, yang diisi dari NUXT_IKIFORM_SECRET_KEY. useRoute().path dan useRequestURL() menyusun alamat halaman baik di server maupun di browser.
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>Ingin lebih jauh dengan Nuxt? Baca Buat formulir feedback dalam aplikasi di Nuxt.
Tanpa server sendiri, browser mengirim langsung ke Ikiform. submit() dari @ikiform/sdk mengirim FormData dan mengembalikan { ok } atau error per field. Jangan pernah menaruh secret key di sini; Allowed domains (domain yang diizinkan) yang melindungi formulir sebagai gantinya.
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>
);
}Kiriman sisi browser yang sama dalam single-file component. Label komentar berubah saat skornya 1 atau 2, sehingga pengunjung yang kecewa memberi jawaban yang lebih spesifik.
<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>- Ganti
frm_xxxxxxxxxxxxxxxxdengan ID formulir Anda dari dashboard. ratingadalah grup radio berisi lima nilai. Ikiform menyimpannya sebagai angka, jadi rata-rata tidak perlu parsing.emailbersifat opsional. Orang menulis lebih jujur saat tidak harus menyebutkan identitas, dan mereka yang ingin dibalas akan mengisinya.- Input tersembunyi
_gotchaadalah honeypot. Orang tidak pernah melihatnya; bot yang mengisi setiap input menandai dirinya sendiri sebagai spam. pagemencatat halaman yang dibahas oleh jawaban. Bagian berikutnya menjelaskan mengapa Anda mengirimnya sendiri.
IKIFORM_SECRET_KEY di environment variable khusus server.Panduan framework di dokumentasi membahas penyiapan lebih dalam: Next.js, Astro, SvelteKit, Nuxt, React, Vue, dan HTML biasa. Template formulir feedback menghasilkan field yang sama sebagai HTML, React, dan server action; template laporan bug dan survei mencakup formulir yang lebih panjang.
Definisikan field-nya
Tempel definisi di tab Fields formulir dan Ikiform menegakkannya di setiap kiriman, dari halaman mana pun. Coba di bawah: preview menjalankan validator Ikiform di browser Anda, jadi skor 0 atau email yang salah format menampilkan 422 yang akan Anda terima.
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
rating menerima bilangan bulat dari 1 sampai max-nya, di sini 5. Nilainya bisa sampai 10, tetapi lima pilihan lebih cepat dijawab.| Field | Tipe | Alasan |
|---|---|---|
rating | rating, maks 5 | Satu klik. Disimpan sebagai angka, jadi Anda bisa menghitung rata-ratanya per halaman |
comment | textarea, hingga 4.000 karakter | Alasan di balik skor |
email | email, opsional | Balas orang yang bertanya. Menjadi reply-to di notifikasi |
page | url, opsional | Halaman yang dibahas oleh jawaban |
Setelah field didefinisikan, nama lain apa pun dalam kiriman ditolak sebagai Unknown field. Jika nanti Anda mengirim konteks tambahan, seperti versi aplikasi atau nama paket, tambahkan field untuknya terlebih dahulu. ID field menggunakan snake_case huruf kecil.
Ketahui halaman yang dibahas
Skor tanpa halaman sulit ditindaklanjuti. Ikiform mencatat metadata browser di setiap kiriman, tetapi salah satu nilainya kurang berguna dari kelihatannya:
| Nilai | Asalnya | Yang Anda dapat |
|---|---|---|
meta.user_agent | Dicatat oleh Ikiform | Browser dan sistem operasi, dari kiriman browser |
meta.origin | Dicatat oleh Ikiform | Origin situs Anda, seperti https://example.com |
meta.referrer | Dicatat oleh Ikiform | Biasanya hanya origin. Browser memakai kebijakan strict-origin-when-cross-origin secara default, yang membuang path saat mengirim ke situs lain |
meta.country | Dicatat oleh Ikiform | Kode negara dua huruf |
page | Dikirim oleh formulir Anda | Path lengkap, jadi Anda tahu halaman mana yang dibahas oleh jawaban |
Itulah sebabnya setiap contoh di atas mengirim page sendiri. Versi HTML dan SPA membaca location; versi server membaca alamat yang sudah diketahui framework-nya. Semuanya membuang query string, yang bisa berisi kata pencarian, ID pelacakan, atau token sekali pakai yang tidak ingin Anda simpan.
meta.user_agent adalah runtime server Anda, bukan browser pengunjung. Untuk feedback halaman, hal ini jarang penting. Untuk laporan bug, ini penting, dan panduan laporan bug meneruskan user agent browser sebagai field.Di mana bertanya
Letak formulir menentukan apa yang Anda dengar. Formulir di footer setiap halaman mendapat sedikit opini yang kuat. Pertanyaan di akhir sebuah tugas mendapat jawaban yang stabil dan spesifik.
| Penempatan | Cocok untuk | Waspadai |
|---|---|---|
| Akhir halaman dokumentasi atau artikel | Menemukan halaman yang tidak menjawab pertanyaan | Volume rendah per halaman. Baca per bagian, bukan per halaman |
| Setelah sebuah tugas di aplikasi Anda, seperti ekspor atau proyek pertama | Rasanya sebuah alur tertentu, selagi masih segar | Tanyakan sekali per orang, bukan setiap kali mereka menyelesaikan tugasnya |
| Tombol mengambang di setiap layar | Apa saja, kapan saja. Lihat widget React | Tombol ini tidak boleh menutupi tombol Anda sendiri, banner cookie, atau gelembung chat |
| Tautan di email | Skor yang ingin Anda pantau dari waktu ke waktu, seperti NPS | Klien email bukan halaman Anda, jadi formulirnya butuh landing page di situs Anda |
Jaga formulir tetap kecil di mana pun letaknya. Tanyakan skor lebih dulu dan tampilkan kotak komentar bersamanya, bukan di langkah kedua. Langkah kedua kehilangan sebagian besar orang, dan skor tanpa alasan pun tetap berharga.
Sampaikan skor rendah ke orang yang tepat
- Setiap jawaban disimpan, berapa pun skornya dan dari halaman mana pun asalnya.
- Ikiform mengirim webhook submission.created bertanda tangan ke relay Anda.
- Relay membaca rating-nya. Skor 3 sampai 5 tidak perlu ditindaklanjuti saat itu juga.
- Skor 1 atau 2 masuk ke chat tim Anda bersama halaman dan komentarnya.
- Jika pengunjung meninggalkan email, seseorang membalas di hari yang sama.
- Seminggu sekali, hasil ekspor menunjukkan rata-rata per halaman, dan di situlah tren terlihat.
Email notifikasi dikirim ke maksimal 10 penerima terkonfirmasi per formulir dan datang untuk setiap jawaban. Itu cukup untuk beberapa jawaban sehari. Lebih dari itu, kirim skor rendah ke chat dengan relay seperti route Next.js ini; kode yang sama berjalan di 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;
}
}verifyWebhookmemeriksa tanda tangan dan timestamp, jadi tidak ada orang lain yang bisa mengirim ke channel Anda lewat URL ini.- Skor di atas 2 menjawab
204dan berhenti. Ikiform menganggap 2xx apa pun sebagai terkirim. - Panggilan Slack yang gagal menjawab
502, dan Ikiform mencoba ulang denganwebhook-idyang sama, jadi gangguan singkat tidak menghilangkan peringatan. - Nilai field adalah apa yang diketik pengunjung. Relay memotong komentar menjadi 500 karakter sebelum sampai ke chat.
Tambahkan endpoint di Webhooks dan salin secret whsec_-nya ke IKIFORM_WEBHOOK_SECRET. Satu workspace bisa memiliki 3 endpoint. Untuk rute tanpa kode, lihat Slack, Discord, dan Google Sheets. Untuk feedback yang perlu menjadi tiket, Linear dan GitHub Issues punya relay yang membuat satu tiket per kiriman.
Tahan bot
Formulir feedback di setiap halaman berarti formulir publik di setiap halaman. Bot akan menemukannya, dan sebagian besar yang mereka kirim berupa tautan. Bawaan Ikiform menangani sebagian besarnya:
| Perlindungan | Yang dihentikan | Setup |
|---|---|---|
| Honeypot | Bot yang mengisi setiap input | Input tersembunyi _gotcha di contoh |
| Skor spam | Tiga tautan atau lebih (+2), frasa spam yang dikenal (+2), email sekali pakai (+2), pengulangan identik (+3). Pada skor 5, kiriman disimpan sebagai spam | Tidak ada |
| Rate limit | Skrip yang mengirim berulang-ulang | Aktif secara default: Standard, 5 per menit per pengunjung, dan 300 per menit untuk seluruh formulir |
| Domain yang diizinkan | Kiriman dari situs lain | Tambahkan domain Anda di pengaturan formulir |
| Pemeriksaan manusia | Bot yang menjalankan browser sungguhan | Atur Spam check ke Ikiform. Kiriman fetch dari browser kemudian butuh token dari challenge() |
Spam mendapat jawaban sukses yang sama dengan kiriman asli, tidak mengirim email atau webhook, dan tidak dihitung dalam pemakaian. Pengunjung asli yang menempel tiga tautan di komentar bisa memicu aturan tautan bersama aturan pengulangan, jadi sesekali lihat filter Spam. Menandai jawaban sebagai bukan spam akan mengirim webhook-nya saat itu. Dokumentasi spam menjelaskan setiap pemeriksaan.
Baca jawabannya
Baca setiap komentar di minggu-minggu pertama. Setelah itu, tampilan yang berguna adalah skor rata-rata per halaman, dari yang terendah, dengan komentarnya di sebelahnya.
Ekspor
Buka Submissions, pilih formulir feedback, pilih rentang tanggal, lalu Export CSV. Kolomnya adalah id, created_at, form, dan status, lalu satu kolom per field, jadi dengan field di atas rating ada di kolom E dan page di kolom H. Satu ekspor memuat hingga 100.000 baris; panduan ekspor membahas API untuk jumlah yang lebih besar.
Impor file ke Google Sheets dan tempel ini di sheet kosong. Hasilnya mendaftar setiap halaman dengan skor rata-rata dan jumlah jawabannya, dari yang terburuk:
=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)- Abaikan halaman yang hanya punya satu atau dua jawaban. Satu pengunjung yang marah bukanlah tren.
- Baca komentar di tiga halaman dengan skor terendah. Perbaikannya biasanya ada di beberapa komentar pertama.
- Setelah Anda mengubah halaman, bandingkan rata-ratanya untuk minggu-minggu sebelum dan sesudahnya, bukan angka sepanjang masa.
Pilih situasi Anda
Formulir di atas mencakup reaksi cepat di halaman mana pun. Halaman-halaman ini membahas feedback dengan tugas berbeda: bug yang perlu direproduksi, permintaan yang perlu ditimbang, skor yang perlu dipantau, atau pelanggan yang akan pergi.
- Formulir laporan bugFormulir laporan bug yang menanyakan langkah dan tingkat keparahan, dan membiarkan halaman menambahkan URL, browser, viewport, dan error terbaru dengan sendirinya.
- Formulir permintaan fiturFormulir permintaan fitur yang menanyakan masalahnya alih-alih solusinya, menyimpan permintaan per tema, menghitung orang bukan baris, dan mengirim email saat fiturnya rilis.
- Widget feedback ReactTombol feedback mengambang untuk aplikasi React yang membuka panel kecil, mengirim layar, versi aplikasi, dan akun bersama setiap jawaban, dan tidak pernah kehilangan draf.
- Feedback beta testerFormulir feedback beta dengan tautan pribadi per tester dan build, dua skala rating, dan ringkasan mingguan yang menunjukkan apakah setiap build makin baik.
- Survei NPSJalankan survei NPS dengan skor 0 sampai 10 di email, pertanyaan lanjutan di halaman Anda sendiri yang berubah sesuai skor, dan script yang menghitung NPS.
- Survei pembatalanTanyakan mengapa pelanggan membatalkan di dalam alur pembatalan SaaS Anda, jaga Skip tetap satu klik, simpan alasannya ke Stripe dan Ikiform, dan jangan pernah menunda pembatalan.
- Formulir feedback Next.jsFormulir feedback dalam aplikasi Next.js: server action yang membaca sesi, mengambil halaman dari request, dan meneruskan rating beserta penggunanya.
- Formulir feedback AstroFormulir feedback dalam aplikasi Astro: action yang mengambil pengguna dari locals middleware, halaman dari request, dan layar dari pola route.
- Formulir feedback TanStack StartFormulir feedback dalam aplikasi TanStack Start: server function POST dengan middleware sesi, route id sebagai konteks, dan halaman dibaca dari header request.
- Formulir feedback React RouterFormulir feedback React Router 8: action di resource route membaca sesi cookie, fetcher mengirim dari layout, dan kiriman tanpa JS di-redirect kembali.
- Formulir feedback SvelteKitFormulir feedback dalam aplikasi SvelteKit: formulir di layout mengirim ke satu form action, yang menambahkan pengguna dari locals dan halaman dari request di server.
- Formulir feedback NuxtFormulir feedback dalam aplikasi Nuxt: server route yang membaca sesi pengguna, mengambil halaman dari Referer, dan tetap bekerja saat JavaScript belum termuat.
Pastikan berfungsi
Periksa daftar ini sebelum memasang formulir di setiap halaman. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari localhost. Untuk go live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat harga.
- Di Allowed domains formulir, tambahkan domain situs Anda, ditambah
localhostselama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja. - Pilih skor, tulis komentar, lalu kirim. Baris terima kasih menggantikan formulir, atau browser mendarat di halaman redirect Anda untuk versi HTML biasa.
- Buka Submissions di dashboard. Jawabannya menampilkan
ratingsebagai angka danpagedengan path lengkap. - Kirim skor
6dengancurlatau konsol browser. Ikiform menjawab422dengan menyebutrating. - Cek inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field, dan Reply terkirim ke pengirim formulir jika formulir punya field
email. - Kirim skor 1. Relay Anda meneruskannya ke chat tim, dan pengiriman di Webhooks menunjukkan
succeeded. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - Tidak ada yang masuk? Ubah filter status di dashboard ke Spam. Spam mendapat jawaban sukses yang sama dengan kiriman asli.
Pemecahan masalah
| Masalah | Yang harus dilakukan |
|---|---|
403 origin_not_allowed, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di Allowed domains formulir. Tambahkan, ditambah localhost untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |
422 dengan Rating must be at most 5 | Formulir mengirim skor yang lebih tinggi dari max field. Ubah nilai radio atau naikkan max di definisi, hingga 10. |
422 dengan Unknown field | Formulir mengirim nama yang tidak ada di definisi, sering kali input tersembunyi yang baru. Tambahkan field dengan ID itu, atau hapus inputnya. |
page kosong | Script yang mengisinya tidak berjalan, atau berjalan sebelum formulirnya ada. Letakkan script setelah formulir, atau atur page saat submit seperti pada contoh SPA. |
meta.referrer hanya menampilkan domain Anda | Memang begitu. Secara default browser hanya mengirim origin pada kiriman lintas situs. Gunakan field page sebagai gantinya. |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter Spam untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |
429 rate_limited | Satu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuai Retry-After, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama. |
402 test_limit_reached | Workspace belum punya paket dan sudah memakai 50 uji gratis dari localhost bulan ini. Pilih paket, atau pakai Send test di dashboard. |
Pertanyaan
Arahkan action formulir ke endpoint backend formulir. Formulirnya bisa berupa HTML biasa di situs statis; backend memvalidasi, menyimpan, dan mengirim email untuk setiap jawaban, serta mengirim webhook jika Anda ingin meneruskannya ke tempat lain.
Kirim alamat halaman sebagai field. Ikiform juga mencatat referrer, tetapi browser biasanya hanya mengirim domain Anda pada kiriman lintas situs, jadi field halaman yang diisi dari location adalah cara yang andal.
Jadikan email opsional. Orang lebih terus terang tanpa email, dan mereka yang ingin dibalas akan mengisinya. Ikiform tetap mencatat browser, origin, referrer, dan negara di setiap jawaban.
Tidak sebagai file. Ikiform menolak kiriman berisi file dengan 422 files_not_supported. Mintalah tautan ke screenshot, atau gunakan alat feedback yang dibangun di sekitar screenshot jika Anda membutuhkannya di setiap laporan.
Tidak dengan field rating, yang berjalan dari 1 hingga maksimal 10. Skor 0 sampai 10 memakai field number dengan min 0, max 10, dan integer true. Panduan NPS menunjukkan penyiapan lengkapnya.
Tambahkan webhook ke relay kecil yang memeriksa rating dan hanya mengirim skor rendah ke Slack atau Discord. Email notifikasi mencakup setiap jawaban; relay mencakup jawaban yang perlu ditanggapi hari itu juga.
Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.