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

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/feedback-form/nextjs

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](https://ikiform.com/pt-br/guides/feedback-form.md#form) é 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](https://ikiform.com/pt-br/docs/guides/nextjs.md).

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

Diagrama: Como uma resposta de feedback em Next.js recebe o usuário e a página no servidor

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`:

```text
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`:

```bash
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.

Formulário: In-app feedback

- **Como esta tela está funcionando para você?** (`rating`, rating, obrigatório)
- **O que a deixaria melhor?** (`comment`, textarea)
- **Página** (`page`, url)
- **Tela** (`screen`, text)
- **Usuário** (`user_id`, text)
- **E-mail** (`email`, email)

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`:

```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`:

```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`:

```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`:

```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](https://ikiform.com/pt-br/docs/rate-limits.md) 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

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

| 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

### Como anexar o usuário logado ao feedback no Next.js?

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.

### Como uma server action do Next.js sabe de qual página veio o feedback?

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.

### Um formulário de feedback deixa minha página Next.js dinâmica?

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.

### O Ikiform é gratuito?

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.

## Mais guias

- [Formulário de feedback para um site ou app](https://ikiform.com/pt-br/guides/feedback-form.md): 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.
- [Widget de feedback em React](https://ikiform.com/pt-br/guides/feedback-form/react-feedback-widget.md): Um 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.
- [Formulário de feedback com React Router](https://ikiform.com/pt-br/guides/feedback-form/react-router.md): Um 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 TanStack Start](https://ikiform.com/pt-br/guides/feedback-form/tanstack-start.md): Um 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 relato de bug](https://ikiform.com/pt-br/guides/feedback-form/bug-report.md): Um 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 funcionalidade](https://ikiform.com/pt-br/guides/feedback-form/feature-request.md): Um 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.
