Formulário de feedback para um site ou app9 min de leitura
Crie um formulário de feedback dentro do app em Next.js
Um 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.
Coloque um pequeno formulário no cliente com uma avaliação de 1 a 5 nas telas sobre as quais você quer feedback e aponte-o para uma server action. A action lê o usuário logado no cookie de sessão, pega a página no Referer da requisição e encaminha tudo ao seu backend de formulários com uma chave secreta. O navegador nunca diz quem é o usuário, então ninguém consegue enviar feedback se passando por outra pessoa.
A aba Next.js do guia principal é um formulário de feedback de página para qualquer visitante. Esta página é para o lado de dentro do seu app, onde as pessoas estão logadas. Ali você já sabe quem está escrevendo e em qual tela, então o formulário faz duas perguntas e o servidor preenche o resto. Configuração do produto, chaves e domínios permitidos estão no guia do Next.js na documentação.
O que muda quando o usuário está logado
Um formulário de feedback público aceita o que o navegador entrega. Um formulário dentro do app não deveria. Se o navegador envia user_id, qualquer um pode alterá-lo nas dev tools e enviar feedback como um colega ou um cliente. Cada valor deve vir do lugar em que se pode confiar para ele:
| Valor | Vem de | Por que daí |
|---|---|---|
rating, comment | O formulário | Só o usuário os conhece |
user_id, email | O cookie de sessão, lido na action | O navegador não consegue forjar uma sessão assinada |
page | O header Referer da requisição da action | Uma server action envia para a página que a renderiza, então o header traz a URL completa |
screen | Uma prop que a página passa, como projects/[id]/settings | Agrupa a página de configurações de todos os projetos sob um só nome, para que as médias façam sentido |
screen é o único valor que passa pelo navegador. É um rótulo para agrupar, não uma permissão, e a action confere o formato antes de ele ser armazenado.
- O usuário escolhe uma nota e talvez escreve um comentário. O formulário envia isso com o nome da tela.
- A server action roda como um POST para a página em que o formulário está, com o cookie de sessão e a página no Referer.
- getUser() lê e confere o cookie de sessão. Sem sessão válida, nada é enviado.
- A action adiciona o id do usuário, o e-mail e o endereço da página, e envia ao Ikiform com a chave secreta.
- A resposta do Ikiform vira o action state: a frase de agradecimento, ou o erro com o comentário ainda digitado.
Configuração do projeto
Quatro arquivos, um dos quais você provavelmente já tem. O layout do App Router é o de sempre:
app/
(app)/
projects/[id]/settings/page.tsx
feedback/
actions.ts
feedback-form.tsx
lib/
user.ts
.env.localCrie uma chave secreta em API keys com o escopo submissions:write e coloque-a em .env.local. Nunca dê a ela o prefixo NEXT_PUBLIC_, que a enviaria para todo navegador.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-usesNa aba Fields do formulário, cole esta definição. Os usuários veem os dois primeiros campos; seu servidor define os outros quatro. Como há um campo email, Reply na notificação vai direto para o usuário.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
0 ou um nome de campo desconhecido, e a prévia responde com o mesmo 422 que a sua action recebe.Construa
Leia o usuário num só lugar
O Next.js recomenda uma camada de acesso a dados: uma função só de servidor que lê e confere a sessão, usada por toda action. O cache do React faz chamadas repetidas numa mesma requisição lerem o cookie uma vez só. decrypt é o que o seu código de sessão já tiver; com uma biblioteca de auth, retorne o usuário dela aqui.
import "server-only";
import { cookies } from "next/headers";
import { cache } from "react";
import { decrypt } from "@/lib/session";
export interface User {
id: string;
email: string;
}
export const getUser = cache(async (): Promise<User | null> => {
const token = (await cookies()).get("session")?.value;
if (!token) return null;
const session = await decrypt(token);
return session?.userId ? { id: session.userId, email: session.email } : null;
});A server action
"use server";
import { headers } from "next/headers";
import { getUser } from "@/lib/user";
export interface FeedbackState {
status: "idle" | "sent" | "error";
message: string;
comment?: string;
}
const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;
function pageFrom(referer: string | null) {
try {
const url = new URL(referer ?? "");
return url.origin + url.pathname;
} catch {
return "";
}
}
export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
const comment = String(formData.get("comment") ?? "").trim().slice(0, 4000);
const user = await getUser();
if (!user) return { status: "error", message: "Your session ended. Sign in again, then send it.", comment };
const rating = Number(formData.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { status: "error", message: "Pick a score from 1 to 5.", comment };
}
const request = await headers();
const screen = String(formData.get("screen") ?? "");
let response: Response;
try {
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: request.get("origin") ?? "",
},
body: JSON.stringify({
rating,
comment,
page: pageFrom(request.get("referer")),
screen: SCREEN.test(screen) ? screen : "unknown",
user_id: user.id,
email: user.email,
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { status: "error", message: "We couldn't send it. Try again in a moment.", comment };
}
const result = await response.json().catch(() => null);
if (result?.ok) return { status: "sent", message: "Thanks. We read every one." };
return {
status: "error",
message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it. Try again.",
comment,
};
}- A verificação de sessão vem primeiro e não lança erro. Um usuário cuja sessão expirou no meio do comentário recebe uma mensagem e mantém o comentário, não uma página de erro.
pageFrommantém só a origem e o caminho doReferer. Query strings num app costumam ter ids e tokens que você não quer numa caixa de feedback.screenprecisa seguir um padrão curto, parecido com um caminho. Qualquer outra coisa é armazenada comounknown, para que um envio forjado não encha a coluna de lixo.Originrepassa a origem do navegador. Em produção é o seu domínio; na sua máquina élocalhost, e é isso que torna os testes locais gratuitos, como explicado abaixo.AbortSignal.timeoutevita que o botão fique girando para sempre quando a rede está lenta.
O formulário
"use client";
import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";
const initial: FeedbackState = { status: "idle", message: "" };
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];
export function FeedbackForm({ screen }: { screen: string }) {
const [state, action, pending] = useActionState(sendFeedback, initial);
if (state.status === "sent") return <p role="status">{state.message}</p>;
return (
<form action={action} aria-busy={pending}>
<fieldset>
<legend>How is this screen working for you?</legend>
{SCORES.map((label, index) => (
<label key={label}>
<input type="radio" name="rating" value={index + 1} required /> {label}
</label>
))}
</fieldset>
<label htmlFor="feedback-comment">What would make it better?</label>
<textarea id="feedback-comment" name="comment" rows={4} maxLength={4000} defaultValue={state.comment} />
<input type="hidden" name="screen" value={screen} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}- A action vai direto em
action. O Next.js a renderiza como um envio de formulário de verdade, então um usuário que clica antes do JavaScript carregar ainda tem o feedback enviado. pendingdouseActionStatedesativa o botão enquanto a action roda, o que impede envios duplicados.- O React reseta o formulário depois de uma action.
defaultValue={state.comment}devolve o comentário quando há erro, para ninguém digitar duas vezes. - Os rótulos dão nome às notas.
BrokeneGreatdizem mais que1e5, e o valor armazenado continua sendo um número.
Coloque numa tela
import { FeedbackForm } from "@/app/feedback/feedback-form";
export default function ProjectSettingsPage() {
return (
<main>
<h1>Project settings</h1>
<aside>
<FeedbackForm screen="projects/[id]/settings" />
</aside>
</main>
);
}Passe o caminho da pasta da rota como screen. Aí a página de configurações de todos os projetos compartilha um nome, e o endereço completo com o id real continua em page.
Honeypot, verificação humana e limites
O input oculto _gotcha é encaminhado como está. O Ikiform armazena um preenchido como spam. A verificação humana não roda, porque um envio com chave secreta a pula, e tudo bem aqui: a action recusa quem não tem sessão, coisa que um bot não tem. O risco que sobra é uma conta logada enviando em loop. O limite de taxa do Ikiform só vê o endereço do seu servidor, então escolha um preset que sirva.
As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.
Renderização e cache
O formulário não lê nada na renderização. Ele não chama cookies() nem headers(), então não deixa a página dinâmica, e com Cache Components ativado pode ficar no shell estático sem um limite <Suspense>. Só a action toca a requisição, e actions sempre rodam por requisição. Se a página já lê a sessão por outros motivos, nada muda.
Deploy
| Host | O que fazer |
|---|---|
| Vercel | Adicione IKIFORM_SECRET_KEY em Environment Variables do projeto para Production e Preview. Actions rodam como functions sem configuração extra. |
| Netlify | A Netlify faz o build do Next.js com a própria integração. Adicione a chave nas variáveis de ambiente do site com o escopo Functions e faça o redeploy. |
| Cloudflare | A Cloudflare roda o Next.js em Workers pela integração OpenNext. Guarde a chave como secret do Worker e coloque-a em .dev.vars para previews locais do build do Worker. |
Atrás de um proxy ou CDN que muda o host, o Next.js rejeita actions cujo Origin não bate. Liste esse domínio em experimental.serverActions.allowedOrigins no next.config.ts.
Teste no localhost de graça
Um workspace sem plano recebe 50 envios de teste por mês, mas só de localhost, que o Ikiform lê do header Origin. Um fetch no servidor não envia Origin próprio, então sem a linha que o repassa, todo teste local voltaria com 402 plan_required e ficaria retido. Com ela, next dev em http://localhost:3000 conta como teste local, e os envios de produção levam o seu domínio real.
Confira se funciona
- Rode
next dev, faça login, abra as configurações de um projeto e envie uma nota 2 com um comentário. - Abra Submissions. A resposta tem
user_id,email,screencomprojects/[id]/settingsepagecom o id real do projeto e sem query string. - Saia da conta em outra aba e envie de novo. O formulário mostra a mensagem de login com o seu comentário ainda na caixa.
- Desative o JavaScript e envie mais uma vez. A página recarrega com a frase de agradecimento.
- Responda ao e-mail de notificação. Ele vai para o endereço do usuário logado.
Pegadinhas
| Problema | O que fazer |
|---|---|
402 plan_required em todo teste local | O envio chegou ao Ikiform sem uma origem localhost. Confira a linha Origin na action e abra o app em localhost, não no endereço de rede da sua máquina. |
page está vazio | Uma Referrer-Policy rígida como no-referrer remove o header, mesmo em requisições da mesma origem. Use strict-origin-when-cross-origin, o padrão. |
| Failed to find Server Action, logo depois de um deploy | Uma aba ainda roda o build anterior, cujo id de action não existe mais. Peça ao usuário que recarregue, e mantenha NEXT_SERVER_ACTIONS_ENCRYPTION_KEY igual em todas as instâncias quando você hospeda por conta própria. |
403 forbidden do Ikiform | A chave não tem submissions:write, pertence a outro workspace ou foi revogada. Crie uma nova. |
422 validation_failed | O envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo. |
Perguntas
Leia a sessão dentro da server action, não no navegador. Chame ali o seu helper de sessão ou de auth e adicione o id e o e-mail do usuário ao envio. Qualquer coisa que o navegador envie sobre identidade pode ser alterada pelo usuário.
Uma server action é um POST para a página que renderiza o formulário, então o header Referer traz a URL dessa página. Mantenha a origem e o caminho e descarte a query string.
Este não. O formulário não lê cookies nem headers enquanto renderiza. Só a action lê a sessão, e ela roda a cada envio.
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.