# Crie um formulário de feedback dentro do app em Nuxt

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

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

Envie um formulário de duas perguntas para uma server route do Nuxt em `server/api/feedback.post.ts`. A rota lê o usuário logado com `getUserSession` do nuxt-auth-utils, pega a página do `Referer` da requisição, valida a nota com Zod e encaminha tudo ao seu backend de formulários com uma chave secreta guardada em `runtimeConfig`. Com JavaScript, o formulário envia via `$fetch` e fica no lugar; sem ele, a mesma rota redireciona de volta com o resultado na URL.

A [aba Nuxt do guia principal](https://ikiform.com/pt-br/guides/feedback-form.md#form) monta feedback de página para qualquer visitante. Dentro de um app Nuxt com contas, o trabalho é mais restrito e os dados são mais ricos: o usuário está logado, o servidor sabe quem ele é, e o router sabe em qual tela ele está. Esta página monta essa versão. Chaves, domínios permitidos e o resto da configuração do produto estão no [guia do Nuxt na documentação](https://ikiform.com/pt-br/docs/guides/nuxt.md).

## O que muda num app Nuxt com login

Num formulário público, o navegador decide o que é armazenado. Aqui, o navegador decide só a nota e o comentário. Tudo que diz quem e onde é adicionado pela server route, onde ninguém consegue editar:

| Campo | Definido por | Como |
| --- | --- | --- |
| `rating`, `comment` | O usuário | Radio buttons e uma caixa de texto |
| `user_id`, `email` | A server route | `getUserSession(event)` lê o cookie de sessão selado |
| `page` | A server route | O header `Referer`, que o navegador envia em envios da mesma origem com o caminho completo |
| `screen` | O componente, conferido pela rota | `useRoute().name`, como `projects-id-settings`, gerado pelo Nuxt a partir do caminho do arquivo da página |

Nomes de rota são uma chave de agrupamento gratuita no Nuxt. A página de configurações de todos os projetos compartilha `projects-id-settings`, então você pode tirar a média das notas por tela enquanto `page` mantém o endereço exato.

Diagrama: Como uma única server route do Nuxt cuida do feedback com e sem JavaScript

1. Depois da hidratação, o componente impede o envio nativo e envia os campos como JSON com $fetch.
2. A server route confere o Origin, lê o usuário da sessão, valida a nota e adiciona a página do Referer.
3. Ela encaminha a resposta ao Ikiform com a chave secreta do runtimeConfig.
4. Uma requisição JSON recebe { sent: true } ou um erro com mensagem, e o formulário se atualiza no lugar.
5. Antes da hidratação, o próprio navegador envia o formulário. A rota faz as mesmas verificações e o mesmo envio.
6. Depois responde com um 303 de volta para a página, com o resultado na query string para o componente mostrar.

A rota distingue os dois pelo content type da requisição. Os dois caminhos armazenam exatamente a mesma resposta.

## Configuração do projeto

Isto usa a estrutura do Nuxt 4, com o código do app em `app/` e o código do servidor na raiz. Instale `nuxt-auth-utils` e `zod`. Se o seu app já faz login de outro jeito, mantenha-o e leia o usuário da sua própria sessão na rota.

`Árvore do projeto`:

```text
app/
  components/FeedbackForm.vue
  pages/projects/[id]/settings.vue
server/
  api/feedback.post.ts
shared/
  types/auth.d.ts
nuxt.config.ts
.env
```

Declare a chave secreta em `runtimeConfig` com um padrão vazio. O Nuxt a preenche em runtime a partir de `NUXT_IKIFORM_SECRET_KEY` e, como a chave não está em `public`, ela só existe no servidor.

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-string
```

Crie a chave em **API keys** com o escopo `submissions:write`. O nuxt-auth-utils sela o cookie de sessão com `NUXT_SESSION_PASSWORD`, que precisa ter pelo menos 32 caracteres; em desenvolvimento ele grava uma para você na primeira execução. Diga a ele como é o seu usuário, para que `session.user.id` seja tipado:

`shared/types/auth.d.ts`:

```ts
declare module "#auth-utils" {
  interface User {
    id: string;
    email: string;
  }
}

export {};
```

Depois cole a definição na aba **Fields** do formulário. Os dois primeiros campos são o formulário; a server route adiciona o resto, e o campo `email` faz o **Reply** de cada notificação ir 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)

A prévia roda o validador do Ikiform, então uma nota `6` mostra o `422` que a rota receberia.

## Construa

### A server route

`server/api/feedback.post.ts`:

```ts
import { z } from "zod";

const Feedback = z.object({
  rating: z.coerce.number().int().min(1).max(5),
  comment: z.string().trim().max(4000).default(""),
  screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
  _gotcha: z.string().default(""),
});

function pageFrom(referer: string | undefined) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export default defineEventHandler(async (event) => {
  const own = getRequestURL(event, { xForwardedHost: true });
  if (getRequestHeader(event, "origin") !== own.origin) {
    throw createError({ status: 403, message: "Posts from other sites are refused." });
  }

  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
  const referer = getRequestHeader(event, "referer");
  const back = (outcome: string) => {
    const target = new URL(referer ?? "/", own.origin);
    return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
  };

  const session = await getUserSession(event);
  if (!session.user) {
    if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
    return back("signed-out");
  }

  const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
  if (!parsed.success) {
    if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
    return back("invalid");
  }

  const { ikiformSecretKey } = useRuntimeConfig(event);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      Origin: own.origin,
    },
    body: JSON.stringify({
      ...parsed.data,
      page: pageFrom(referer),
      user_id: session.user.id,
      email: session.user.email,
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return wantsJson ? { sent: true } : back("sent");
  if (wantsJson) {
    throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
  }
  return back("failed");
});
```

- O sufixo `.post.ts` faz o arquivo responder só a `POST`.
- A verificação de Origin roda antes de tudo. `readBody`, que `readValidatedBody` usa, também interpreta corpos urlencoded, então sem ela outro site poderia enviar um formulário simples para esta rota com o cookie do seu usuário anexado.
- `getUserSession` não lança erro. A própria rota escolhe a resposta: um `401` para `$fetch`, um redirect para um envio simples. `requireUserSession` mandaria uma página `401` para um usuário sem JavaScript.
- `z.coerce.number()` transforma o `"4"` urlencoded em `4`. `.catch("unknown")` armazena um `screen` malformado como `unknown` em vez de recusar a resposta inteira.
- `back` mantém só o caminho do `Referer`, então um redirect nunca consegue sair do seu site.

### O componente

`app/components/FeedbackForm.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  "signed-out": "Your session ended. Sign in again, then send it.",
  invalid: "Pick a score from 1 to 5.",
  failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");

async function send(event: SubmitEvent) {
  const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/feedback", { method: "POST", body });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
    status.value = "idle";
  }
}
</script>

<template>
  <template v-if="loggedIn">
    <p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
    <form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
      <fieldset>
        <legend>How is this screen working for you?</legend>
        <label v-for="(label, index) in SCORES" :key="label">
          <input type="radio" name="rating" :value="index + 1" required /> {{ label }}
        </label>
      </fieldset>
      <label for="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000" />
      <input type="hidden" name="screen" :value="screen" />
      <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>
</template>
```

- `method` e `action` são atributos de verdade, então antes da hidratação o navegador envia o formulário para a rota. Quando o Vue está rodando, `@submit.prevent` assume.
- O corpo JSON avisa a rota de que ela pode responder com JSON. O corpo vem de `FormData`, então o honeypot e `screen` vão junto sem código extra.
- Num `$fetch` com falha, o `data.message` do erro é a mensagem do `createError` na rota.
- Depois de um envio simples, `?feedback=` na URL define o estado inicial, então o agradecimento ou o erro aparece na página recarregada.
- `useUserSession` esconde o formulário de visitantes deslogados. É uma conveniência, não uma verificação; quem verifica é a rota.

Coloque `<FeedbackForm />` em qualquer página da parte logada do seu app, como `app/pages/projects/[id]/settings.vue`. O Nuxt faz o auto-import.

## Honeypot, verificação humana e limites

O input `_gotcha` chega ao Ikiform com o resto dos campos, e um preenchido é armazenado como spam. A verificação humana do Ikiform é pulada em envios com chave secreta, e aqui a sessão faz esse papel: a rota recusa quem não está logado. O que sobra é uma conta logada enviando em loop, que o Ikiform só consegue contar pelo endereço do seu servidor.

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.

## Escolhas de renderização

O formulário precisa de renderização no servidor para o resultado sem JavaScript. Numa rota com `ssr: false` em `routeRules`, a página fica em branco até o JavaScript rodar, então o envio simples nunca acontece e `?feedback=` só é lido no navegador. Tudo bem, isso só remove o fallback. Não coloque o formulário numa rota `prerender: true`: o HTML é gerado uma vez, então `useUserSession` não vê ninguém e o formulário fica ausente até a hidratação.

## Deploy

| Host | O que fazer |
| --- | --- |
| Vercel | O Nitro detecta a Vercel, então nenhum preset é necessário. Adicione `NUXT_IKIFORM_SECRET_KEY` e `NUXT_SESSION_PASSWORD` em **Environment Variables** e faça o redeploy. |
| Netlify | Também é detectada. Adicione as duas variáveis nas configurações de ambiente do site com o escopo Functions; a server route roda como função. |
| Cloudflare | Faça o build com o preset `cloudflare_module` para Workers, ou conecte o repositório ao Pages, que é detectado. Guarde os dois valores como secrets e coloque-os em `.dev.vars` para o `wrangler dev`. |

Na Cloudflare, os valores de ambiente só existem enquanto uma requisição roda. A rota lê `useRuntimeConfig(event)` dentro do handler, que é onde isso precisa estar.

## Teste no localhost de graça

Sem plano, um workspace aceita 50 envios de teste por mês a partir de `localhost`, julgados pelo header `Origin`. Um `fetch` de uma rota do Nitro não tem navegador por trás e não envia `Origin`, a menos que você defina um. Por isso a rota repassa `own.origin`: no `nuxt dev` ele é `http://localhost:3000` e conta como teste, e em produção é o seu domínio. Sem ele, todo teste local volta com `402 plan_required`.

## Confira se funciona

1. Rode `nuxt dev`, faça login, abra a página de configurações de um projeto e envie uma nota 2 com um comentário.
2. Em **Submissions**, confira que `user_id` e `email` batem com a sua conta, que `screen` é `projects-id-settings` e que `page` traz o caminho real.
3. Desligue o JavaScript e envie de novo. A página recarrega com a frase de agradecimento e `?feedback=sent` no endereço.
4. Envie para `/api/feedback` com `curl` e sem `Origin`. A rota responde `403` e nada chega ao Ikiform.
5. Apague o cookie de sessão e envie a partir da página aberta. O formulário mostra a mensagem de login.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| `403` da sua própria rota em produção | Um proxy muda o host, então `getRequestURL` discorda do `Origin` do navegador. Faça o proxy enviar `X-Forwarded-Host`, que `xForwardedHost: true` lê. |
| `screen` é `unknown` em todas as respostas | Nomes de rota com sufixo de idioma, como os do `@nuxtjs/i18n`, têm caracteres que o padrão recusa. Amplie a regex ou remova o sufixo no componente. |
| A chave secreta fica vazia em produção | O nome da variável precisa ser `NUXT_` mais a chave em upper snake case, `NUXT_IKIFORM_SECRET_KEY`, e a chave precisa existir em `runtimeConfig`. |
| `402 plan_required` nos testes locais | O envio saiu sem `Origin`. Mantenha o header `Origin: own.origin` e abra o app em `localhost`. |
| `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 pegar o usuário logado numa server route do Nuxt?

Com o nuxt-auth-utils, chame getUserSession(event) ou requireUserSession(event) dentro do handler e leia session.user. Sem ele, defina event.context.user num server middleware e leia esse valor. Nunca pegue o usuário do corpo da requisição.

### Um formulário Nuxt funciona sem JavaScript?

Sim. Dê ao formulário um method e um action de verdade apontando para uma server route, e faça a rota redirecionar de volta com um 303 quando a requisição não for JSON. Depois da hidratação, intercepte o submit e use $fetch.

### Como o Nuxt lê variáveis de ambiente NUXT_?

Declare a chave em runtimeConfig no nuxt.config, e o Nuxt a sobrescreve em runtime a partir de uma variável de ambiente chamada NUXT_ mais a chave em upper snake case. Chaves fora de public ficam no servidor.

### 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.
- [Formulário de feedback em SvelteKit](https://ikiform.com/pt-br/guides/feedback-form/sveltekit.md): Um 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 Astro](https://ikiform.com/pt-br/guides/feedback-form/astro.md): Um 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 Next.js](https://ikiform.com/pt-br/guides/feedback-form/nextjs.md): 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.
- [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.
