Guias17 min de leitura
Adicione um formulário de feedback ao seu site ou app
Coloque um formulário de feedback em qualquer página ou dentro do seu app: uma nota, um comentário, a página de origem, filtro de spam e alertas para notas baixas.
Adicione um formulário curto com uma nota de 1 a 5, um comentário opcional e um e-mail opcional, e envie a um backend de formulários para não precisar rodar um servidor para isso. Mande o endereço da página junto com cada resposta, porque o navegador não informa a URL completa por conta própria. Encaminhe as notas baixas para o chat da equipe no mesmo dia e guarde todas as respostas em um só lugar para ver tendências.
A maioria das pessoas nunca conta o que está errado no seu site. Elas simplesmente vão embora. Um pequeno formulário de feedback pega as poucas que vão falar, no momento em que percebem, na página que causou o problema. Este guia monta um que serve para qualquer site ou app, envia a página de origem, mantém os bots longe e coloca as respostas insatisfeitas na frente da sua equipe no mesmo dia.
Como funciona um formulário de feedback
O formulário envia ao Ikiform. O Ikiform valida a nota, verifica se o envio é spam, armazena, avisa sua equipe por e-mail e envia um webhook assinado. A partir do webhook, as notas baixas vão para um canal de chat e todas as respostas podem ir para uma planilha ou banco de dados.
- Um visitante escolhe uma nota, talvez escreva um comentário, e o formulário envia tudo com o endereço da página.
- O Ikiform confere se a nota é um número inteiro de 1 a 5, roda as verificações de spam e armazena a resposta.
- A resposta aparece no painel, onde você pode filtrá-la e exportá-la como CSV.
- O Ikiform envia um e-mail para a sua equipe. Quando o visitante deixou um e-mail, o Reply (responder) vai direto para ele.
- O Ikiform envia um webhook submission.created assinado com os campos e os metadados do navegador.
- Um pequeno relay posta só as notas baixas no chat da equipe, para que sejam lidas no mesmo dia.
Formas de coletar feedback
Cada tipo de ferramenta foi feito para um trabalho diferente. Escolha pelo que você precisa ver quando chega uma resposta, não pela aparência do widget.
| Abordagem | Bom em | Contras |
|---|---|---|
| Ferramentas de feedback para sites, como Hotjar ou Usersnap | Capturas de tela, anotações e contexto da página sem código além de uma tag script. | Um script de terceiros em todas as páginas, respostas armazenadas no produto deles e preço definido pelos planos deles. |
| O widget de um rastreador de erros, como o Sentry User Feedback | Liga um relato ao erro e, com o session replay ligado, a uma gravação do que aconteceu. | Feito para bugs, não para opiniões. Você precisa do SDK do rastreador no seu app primeiro. |
| Criadores de formulários, como Typeform, Tally ou Google Forms | Formulários caprichados sem código. Bom para uma pesquisa que você manda por link. | Um iframe ou uma página separada, então a resposta perde a página a que se refere, a menos que você a passe na URL. |
| Quadros de feedback, como o Canny | Votação pública e atualizações de status em pedidos de funcionalidades. | Feito para ideias, não para reações rápidas. As respostas são públicas por definição. |
| Sua própria rota de API e banco de dados | Controle total sobre os dados e qualquer lógica. | Você mesmo constrói filtro de spam, limites de taxa, e-mail de notificação e exportação, e precisa de um servidor. |
| Um backend de formulários, como o Ikiform | Seu próprio markup em qualquer site ou app. Validação, filtro de spam, armazenamento, e-mail e webhooks assinados sem servidor. | Sem capturas de tela, porque upload de arquivos não é suportado, e sem votação nem session replay. |
Se você precisa de capturas de tela em todo relato, uma ferramenta de feedback ou um rastreador de erros justifica o script. Se você quer que o formulário tenha a cara do seu site, manter as respostas portáteis e decidir você mesmo para onde elas vão, use um backend de formulários. O resto deste guia faz isso.
O formulário
Três perguntas bastam: uma nota, um comentário e um e-mail opcional. A nota é a que as pessoas respondem; o comentário é onde está o valor; o e-mail é como você fecha o ciclo. Escolha sua stack abaixo; a escolha vale para todos os exemplos de código destes guias.
Um bloco de markup e um script curto. Sem JavaScript, o formulário ainda envia e redireciona; com ele, o visitante fica na página e vê o erro do servidor ao lado do botão. O script preenche page com o endereço sem a query string, para que tokens em URLs nunca cheguem à sua caixa de entrada.
<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>A server action lê o header Referer do seu próprio envio. O formulário e a action compartilham a mesma origem, então o navegador manda a URL completa da página, e a action guarda só a origem e o caminho. Nenhum input oculto é necessário.
"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>
);
}Quer ir além com Next.js? Leia Crie um formulário de feedback dentro do app em Next.js.
A página conhece o próprio endereço enquanto renderiza, então Astro.url preenche o input oculto page no servidor. Inputs vazios chegam à action como null, que o Ikiform trata como ausente. A página precisa ser renderizada sob demanda para o resultado da action aparecer.
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>
)}Quer ir além com Astro? Leia Crie um formulário de feedback dentro do app em Astro.
Uma server function POST confere a nota antes de qualquer coisa sair do seu servidor. O componente define page a partir de window.location no momento do envio, então ela fica correta mesmo depois de navegação no lado do cliente.
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>
);
}Quer ir além com TanStack Start? Leia Crie um formulário de feedback dentro do app em TanStack Start.
Uma resource route guarda a action, e useFetcher envia para ela de qualquer página sem navegação. Registre a rota como api/feedback em app/routes.ts. useLocation fornece o caminho, e a action o resolve em relação à própria URL, que tem a origem do seu site.
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>
);
}Quer ir além com React Router? Leia Crie um formulário de feedback dentro do app com React Router.
Um endpoint +server.ts encaminha o envio, então o componente pode ficar num layout e funcionar em todas as páginas. page de $app/state dá a URL atual. Declare IKIFORM_SECRET_KEY em src/env.ts com defineEnvVars para que $app/env/private consiga lê-la. O endpoint devolve Response.json(), já que o SvelteKit 3 descontinua o 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}Quer ir além com SvelteKit? Leia Crie um formulário de feedback dentro do app em SvelteKit.
Uma server route guarda a chave em runtimeConfig.ikiformSecretKey, preenchida a partir de NUXT_IKIFORM_SECRET_KEY. useRoute().path e useRequestURL() montam o endereço da página tanto no servidor quanto no navegador.
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>Quer ir além com Nuxt? Leia Crie um formulário de feedback dentro do app em Nuxt.
Sem servidor próprio, o navegador envia direto ao Ikiform. submit() de @ikiform/sdk manda o FormData e devolve { ok } ou os erros de campo. Nunca coloque uma chave secreta aqui; quem protege o formulário é 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>
);
}O mesmo envio pelo navegador num single-file component. O rótulo do comentário muda quando a nota é 1 ou 2, o que rende respostas mais específicas de visitantes insatisfeitos.
<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>- Troque
frm_xxxxxxxxxxxxxxxxpelo id do seu formulário, que está no painel. ratingé um grupo de radio com cinco valores. O Ikiform o armazena como número, então médias não exigem parsing.emailé opcional. As pessoas escrevem com mais honestidade quando não precisam se identificar, e quem quer uma resposta vai deixá-lo.- O input oculto
_gotchaé o honeypot. As pessoas nunca o veem; bots que preenchem todos os inputs se marcam como spam. pageregistra a qual página a resposta se refere. A próxima seção explica por que você mesmo a envia.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.Os guias de frameworks na documentação cobrem a configuração com mais detalhes: Next.js, Astro, SvelteKit, Nuxt, React, Vue e HTML puro. O modelo de formulário de feedback gera os mesmos campos em HTML, React e server action; os modelos de relato de bug e de pesquisa cobrem os formulários mais longos.
Defina os campos
Cole uma definição na aba Fields do formulário e o Ikiform a aplica em todo envio, de qualquer página. Teste abaixo: a prévia roda o validador do Ikiform no seu navegador, então uma nota 0 ou um e-mail malformado mostra o 422 que você receberia de volta.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
rating aceita um número inteiro de 1 até o seu max, aqui 5. Ele pode ir até 10, mas cinco opções são mais rápidas de responder.| Campo | Tipo | Por quê |
|---|---|---|
rating | rating, máx. 5 | Um clique. Armazenado como número, então dá para tirar a média por página |
comment | textarea, até 4.000 caracteres | O motivo por trás da nota |
email | email, opcional | Responder a quem faz uma pergunta. Vira o reply-to da notificação |
page | url, opcional | A qual página a resposta se refere |
Com campos definidos, qualquer outro nome no envio é recusado como Unknown field. Se depois você mandar mais contexto, como uma versão do app ou o nome de um plano, adicione um campo para ele antes. Ids de campo usam snake_case em minúsculas.
Saiba a qual página se refere
Uma nota sem página é difícil de usar. O Ikiform registra os metadados do navegador em cada envio, mas um desses valores é menos útil do que parece:
| Valor | De onde vem | O que você recebe |
|---|---|---|
meta.user_agent | Registrado pelo Ikiform | O navegador e o sistema operacional, de um envio pelo navegador |
meta.origin | Registrado pelo Ikiform | A origem do seu site, como https://example.com |
meta.referrer | Registrado pelo Ikiform | Normalmente só a origem. Os navegadores usam por padrão a política strict-origin-when-cross-origin, que remove o caminho ao enviar para outro site |
meta.country | Registrado pelo Ikiform | Um código de país de duas letras |
page | Enviado pelo seu formulário | O caminho completo, para você saber a qual página a resposta se refere |
É por isso que todos os exemplos acima enviam page por conta própria. As versões HTML e SPA leem location; as versões de servidor leem o endereço que o próprio framework já conhece. Todas removem a query string, que pode conter termos de busca, ids de rastreamento ou tokens de uso único que você não quer armazenar.
meta.user_agent é o runtime do seu servidor, não o navegador do visitante. Para feedback sobre páginas isso raramente importa. Para relatos de bug importa, e o guia de relato de bug encaminha o user agent do navegador como um campo.Onde perguntar
O lugar do formulário decide o que você ouve. Um formulário no rodapé de todas as páginas recebe um fio de opiniões fortes. Uma pergunta no fim de uma tarefa recebe respostas constantes e específicas.
| Posição | Bom para | Cuidado com |
|---|---|---|
| Fim de uma página de documentação ou artigo | Encontrar páginas que não respondem à pergunta | Pouco volume por página. Leia por seção, não por página |
| Depois de uma tarefa no seu app, como uma exportação ou o primeiro projeto | Como um fluxo específico é percebido, enquanto ainda está fresco | Pergunte uma vez por pessoa, não toda vez que ela terminar a tarefa |
| Um botão flutuante em todas as telas | Qualquer coisa, a qualquer hora. Veja o widget em React | Ele não pode cobrir seus próprios botões, banners de cookies ou bolhas de chat |
| Um link num e-mail | Notas que você quer acompanhar ao longo do tempo, como o NPS | O cliente de e-mail não é a sua página, então o formulário precisa de uma landing page no seu site |
Mantenha o formulário pequeno onde quer que ele fique. Peça a nota primeiro e mostre a caixa de comentário junto, não numa segunda etapa. Uma segunda etapa perde a maioria das pessoas, e uma nota sem motivo ainda vale a pena.
Coloque as notas baixas na frente das pessoas
- Toda resposta é armazenada, seja qual for a nota e a página de origem.
- O Ikiform envia um webhook submission.created assinado para o seu relay.
- O relay lê a nota. Notas de 3 a 5 não precisam de mais nada de imediato.
- Uma nota 1 ou 2 vai para o chat da equipe com a página e o comentário.
- Se o visitante deixou um e-mail, alguém responde no mesmo dia.
- Uma vez por semana, a exportação mostra as médias por página, que é onde as tendências aparecem.
Os e-mails de notificação vão para até 10 destinatários confirmados por formulário e chegam a cada resposta. Isso funciona para algumas respostas por dia. Acima disso, mande as notas baixas para o chat com um relay como esta rota do Next.js; o mesmo código roda num 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;
}
}verifyWebhookconfere a assinatura e o timestamp, para que ninguém mais consiga postar no seu canal por esta URL.- Notas acima de 2 respondem
204e param. O Ikiform trata qualquer 2xx como entregue. - Uma chamada ao Slack que falha responde
502, e o Ikiform tenta de novo com o mesmowebhook-id, então uma queda curta não faz perder um alerta. - Os valores dos campos são o que o visitante digitou. O relay corta o comentário em 500 caracteres antes de chegar ao chat.
Adicione o endpoint em Webhooks e copie o secret whsec_ dele para IKIFORM_WEBHOOK_SECRET. Um workspace pode ter 3 endpoints. Para caminhos sem código, veja Slack, Discord e Google Sheets. Para feedback que deve virar um ticket, Linear e GitHub Issues tem um relay que cria um por envio.
Mantenha os bots longe
Um formulário de feedback em todas as páginas é um formulário público em todas as páginas. Os bots o encontram, e quase tudo o que mandam são links. Os padrões do Ikiform cuidam da maior parte:
| Defesa | O que ela barra | Configuração |
|---|---|---|
| Honeypot | Bots que preenchem todos os inputs | O input oculto _gotcha nos exemplos |
| Pontuação de spam | Três ou mais links (+2), frases de spam conhecidas (+2), e-mails descartáveis (+2), repetições idênticas (+3). Com 5, é armazenado como spam | Nenhuma |
| Limites de taxa | Scripts que enviam em loop | Ligado por padrão: Standard, 5 por minuto por visitante, e 300 por minuto para o formulário inteiro |
| Domínios permitidos | Envios de outros sites | Adicione seu domínio nas configurações do formulário |
| Verificação humana | Bots que rodam um navegador de verdade | Defina Spam check como Ikiform. Envios por fetch no navegador passam a precisar de um token de challenge() |
O spam recebe a mesma resposta de sucesso que um envio real, não dispara e-mail nem webhook e não conta no uso. Um visitante real que cola três links num comentário pode acionar a regra de links junto com uma repetição, então olhe o filtro Spam de vez em quando. Marcar uma resposta como não spam envia o webhook dela nesse momento. A documentação de spam explica cada verificação.
Leia as respostas
Leia todos os comentários nas primeiras semanas. Depois disso, a visão útil é a nota média por página, da menor para a maior, com os comentários ao lado.
Exportar
Abra Submissions, escolha o formulário de feedback, selecione um intervalo de datas e clique em Export CSV. As colunas são id, created_at, form e status, depois uma por campo, então com os campos acima rating é a coluna E e page é a coluna H. Uma exportação comporta até 100.000 linhas; o guia de exportação cobre a API para mais que isso.
Importe o arquivo no Google Sheets e coloque isto numa planilha vazia. Ela lista cada página com sua nota média e o número de respostas, da pior para a melhor:
=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)- Ignore páginas com só uma ou duas respostas. Um visitante irritado não é uma tendência.
- Leia os comentários das três páginas com notas mais baixas. A correção costuma estar nos primeiros.
- Depois de mudar uma página, compare a média dela nas semanas antes e depois, não o número de todos os tempos.
Escolha o seu caso
O formulário acima cobre reações rápidas em qualquer página. Estas páginas cobrem feedback com outro objetivo: um bug para reproduzir, um pedido para avaliar, uma nota para acompanhar ou um cliente de saída.
- Formulário de relato de bugUm formulário de relato de bug que pede às pessoas os passos e a gravidade, e deixa a página adicionar sozinha a URL, o navegador, o viewport e os erros recentes.
- Formulário de pedido de funcionalidadeUm formulário de pedido de funcionalidade que pergunta pelo problema em vez da solução, armazena pedidos por tema, conta pessoas, não linhas, e avisa quando sair.
- Widget de feedback em ReactUm botão de feedback flutuante para um app React que abre um pequeno painel, envia a tela, a versão do app e a conta com cada resposta e nunca perde um rascunho.
- Feedback de beta testersUm formulário de feedback de beta com um link pessoal por tester e build, duas escalas de nota e um resumo semanal que mostra se cada build melhorou.
- Pesquisa NPSRode uma pesquisa NPS com a nota de 0 a 10 no e-mail, uma pergunta de acompanhamento na sua própria página que muda com a nota e um script que calcula o NPS.
- Pesquisa de cancelamentoPergunte por que os clientes cancelam dentro do fluxo de cancelamento do seu SaaS, deixe o Pular a um clique, salve o motivo no Stripe e no Ikiform e nunca atrase o cancelamento.
- Formulário de feedback em Next.jsUm formulário de feedback dentro do app em Next.js: uma server action que lê a sessão, pega a página da requisição e encaminha uma avaliação com o usuário anexado.
- Formulário de feedback em AstroUm formulário de feedback dentro do app em Astro: uma action que pega o usuário dos locals do middleware, a página da requisição e a tela do padrão da rota.
- Formulário de feedback em TanStack StartUm formulário de feedback dentro do app em TanStack Start: uma server function POST com middleware de sessão, o id da rota como contexto e a página lida dos headers.
- Formulário de feedback com React RouterUm formulário de feedback com React Router 8: a action de uma resource route lê a sessão do cookie, um fetcher envia do layout e um envio sem JS redireciona de volta.
- Formulário de feedback em SvelteKitUm formulário de feedback dentro do app em SvelteKit: um formulário no layout envia para uma form action, que adiciona no servidor o usuário de locals e a página da requisição.
- Formulário de feedback em NuxtUm formulário de feedback dentro do app em Nuxt: uma server route que lê a sessão do usuário, pega a página do Referer e funciona mesmo antes do JavaScript carregar.
Confira se funciona
Repasse isto antes de colocar o formulário em todas as páginas. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Escolha uma nota, escreva um comentário e envie. A frase de agradecimento substitui o formulário, ou o navegador cai na sua página de redirecionamento na versão em HTML puro.
- Abra Submissions no painel. A resposta mostra
ratingcomo número epagecom o caminho completo. - Envie uma nota
6comcurlou pelo console do navegador. O Ikiform responde422apontandorating. - Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e Reply vai para quem enviou quando o formulário tem um campo
email. - Envie uma nota 1. Seu relay a posta no chat da equipe, e a entrega em Webhooks mostra
succeeded. - Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook. - Não chegou nada? Mude o filtro de status do painel para Spam. Spam recebe a mesma resposta de sucesso de um envio real.
Solução de problemas
| Problema | O que fazer |
|---|---|
403 origin_not_allowed, ou todo envio cai no spam | O domínio da página não está em Allowed domains do formulário. Adicione-o, mais localhost para testes locais. Subdomínios de um domínio listado são permitidos. |
422 com Rating must be at most 5 | O formulário manda uma nota maior que o max do campo. Mude os valores do radio ou aumente o max na definição, até 10. |
422 com um Unknown field | O formulário manda um nome que não está na definição, geralmente um input oculto novo. Adicione um campo com esse id ou remova o input. |
page está vazio | O script que o preenche não rodou, ou rodou antes de o formulário existir. Coloque-o depois do formulário, ou defina page no momento do envio como fazem os exemplos de SPA. |
meta.referrer só mostra seu domínio | Esperado. Por padrão, os navegadores mandam só a origem em envios entre sites. Use o campo page no lugar. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro Spam para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |
429 rate_limited | Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o Retry-After, ou escolha Relaxed ou Custom para formulários que enviam a partir de um único IP compartilhado. |
402 test_limit_reached | O workspace não tem plano e já usou os 50 testes gratuitos de localhost deste mês. Escolha um plano ou use Send test no painel. |
Perguntas
Aponte o action do formulário para o endpoint de um backend de formulários. O formulário pode ser HTML puro num site estático; o backend valida, armazena e avisa por e-mail sobre cada resposta, e envia um webhook se você quiser a resposta em outro lugar.
Envie o endereço da página como um campo. O Ikiform também registra o referrer, mas os navegadores normalmente mandam só o seu domínio num envio entre sites, então um campo de página preenchido a partir de location é o jeito confiável.
Deixe o e-mail opcional. As pessoas são mais sinceras sem ele, e quem quer uma resposta vai deixá-lo. O Ikiform ainda registra o navegador, a origem, o referrer e o país de cada resposta.
Não como arquivo. O Ikiform recusa envios com arquivos com 422 files_not_supported. Peça um link para uma captura de tela, ou use uma ferramenta de feedback feita em torno de capturas se você precisar de uma em todo relato.
Não com um campo rating, que vai de 1 a no máximo 10. Uma nota de 0 a 10 usa um campo number com min 0, max 10 e integer true. O guia de NPS mostra a configuração completa.
Adicione um webhook para um pequeno relay que confere a nota e posta só as notas baixas no Slack ou no Discord. Os e-mails de notificação cobrem todas as respostas; o relay cobre as que precisam de resposta no mesmo dia.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.