Formulário de feedback para um site ou app9 min de leitura
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.
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 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.
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.
- Depois da hidratação, o componente impede o envio nativo e envia os campos como JSON com $fetch.
- A server route confere o Origin, lê o usuário da sessão, valida a nota e adiciona a página do Referer.
- Ela encaminha a resposta ao Ikiform com a chave secreta do runtimeConfig.
- Uma requisição JSON recebe { sent: true } ou um erro com mensagem, e o formulário se atualiza no lugar.
- Antes da hidratação, o próprio navegador envia o formulário. A rota faz as mesmas verificações e o mesmo envio.
- Depois responde com um 303 de volta para a página, com o resultado na query string para o componente mostrar.
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.
app/
components/FeedbackForm.vue
pages/projects/[id]/settings.vue
server/
api/feedback.post.ts
shared/
types/auth.d.ts
nuxt.config.ts
.envDeclare 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.
export default defineNuxtConfig({
modules: ["nuxt-auth-utils"],
runtimeConfig: {
ikiformSecretKey: "",
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-stringCrie 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:
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.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
6 mostra o 422 que a rota receberia.Construa
A server route
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.tsfaz o arquivo responder só aPOST. - A verificação de Origin roda antes de tudo.
readBody, quereadValidatedBodyusa, 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. getUserSessionnão lança erro. A própria rota escolhe a resposta: um401para$fetch, um redirect para um envio simples.requireUserSessionmandaria uma página401para um usuário sem JavaScript.z.coerce.number()transforma o"4"urlencoded em4..catch("unknown")armazena umscreenmalformado comounknownem vez de recusar a resposta inteira.backmantém só o caminho doReferer, então um redirect nunca consegue sair do seu site.
O componente
<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>methodeactionsã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.preventassume.- O corpo JSON avisa a rota de que ela pode responder com JSON. O corpo vem de
FormData, então o honeypot escreenvão junto sem código extra. - Num
$fetchcom falha, odata.messagedo erro é a mensagem docreateErrorna 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. useUserSessionesconde 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 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
- 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. - Em Submissions, confira que
user_ideemailbatem com a sua conta, quescreenéprojects-id-settingse quepagetraz o caminho real. - Desligue o JavaScript e envie de novo. A página recarrega com a frase de agradecimento e
?feedback=sentno endereço. - Envie para
/api/feedbackcomcurle semOrigin. A rota responde403e nada chega ao Ikiform. - 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
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.
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.
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.
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.