Formulário de feedback para um site ou app

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.

Preet SutharFundador, Ikiform

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:

ValorVem dePor que daí
rating, commentO formulárioSó o usuário os conhece
user_id, emailO cookie de sessão, lido na actionO navegador não consegue forjar uma sessão assinada
pageO header Referer da requisição da actionUma server action envia para a página que a renderiza, então o header traz a URL completa
screenUma prop que a página passa, como projects/[id]/settingsAgrupa 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.

Como uma resposta de feedback em Next.js recebe o usuário e a página no servidorFeedbackFormnota,comentário,telaserveractionPOSTparaapáginaatualgetUser()lê ocookiedesessãoIkiformchavesecreta,armazenaactionstateobrigado,ou oerroestadodeerroentrardenovo,comentáriomantidoFormDatacookies()+ usuário, páginaresultadosem sessãoComo uma resposta de feedback em Next.js recebe o usuário e a página no servidorFeedbackFormnota, comentário, telaserver actionPOST para a páginaatualgetUser()lê o cookie desessãoIkiformchave secreta,armazenaaction stateobrigado, ou o erroestado deerroentrar denovo,comentáriomantidoFormDatacookies()+ usuário, páginaresultadosem sessão
  1. O usuário escolhe uma nota e talvez escreve um comentário. O formulário envia isso com o nome da tela.
  2. 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.
  3. getUser() lê e confere o cookie de sessão. Sem sessão válida, nada é enviado.
  4. 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.
  5. A resposta do Ikiform vira o action state: a frase de agradecimento, ou o erro com o comentário ainda digitado.
A sessão é lida quando o formulário é enviado, não quando a página é renderizada. A página ao redor do formulário pode continuar estática.

Configuração do projeto

Quatro arquivos, um dos quais você provavelmente já tem. O layout do App Router é o de sempre:

Árvore do projeto
app/
  (app)/
    projects/[id]/settings/page.tsx
  feedback/
    actions.ts
    feedback-form.tsx
lib/
  user.ts
.env.local

Crie 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.

.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses

Na 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.

Como esta tela está funcionando para você?

Definido pelo seu servidor a partir da requisição.

Tente uma nota 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.

lib/user.ts
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

app/feedback/actions.ts
"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.
  • pageFrom mantém só a origem e o caminho do Referer. Query strings num app costumam ter ids e tokens que você não quer numa caixa de feedback.
  • screen precisa seguir um padrão curto, parecido com um caminho. Qualquer outra coisa é armazenada como unknown, para que um envio forjado não encha a coluna de lixo.
  • Origin repassa 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.timeout evita que o botão fique girando para sempre quando a rede está lenta.

O formulário

app/feedback/feedback-form.tsx
"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.
  • pending do useActionState desativa 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. Broken e Great dizem mais que 1 e 5, e o valor armazenado continua sendo um número.

Coloque numa tela

app/(app)/projects/[id]/settings/page.tsx
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

HostO que fazer
VercelAdicione IKIFORM_SECRET_KEY em Environment Variables do projeto para Production e Preview. Actions rodam como functions sem configuração extra.
NetlifyA 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.
CloudflareA 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

  1. Rode next dev, faça login, abra as configurações de um projeto e envie uma nota 2 com um comentário.
  2. Abra Submissions. A resposta tem user_id, email, screen com projects/[id]/settings e page com o id real do projeto e sem query string.
  3. 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.
  4. Desative o JavaScript e envie mais uma vez. A página recarrega com a frase de agradecimento.
  5. Responda ao e-mail de notificação. Ele vai para o endereço do usuário logado.

Pegadinhas

ProblemaO que fazer
402 plan_required em todo teste localO 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á vazioUma 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 deployUma 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 IkiformA chave não tem submissions:write, pertence a outro workspace ou foi revogada. Crie uma nova.
422 validation_failedO 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.