Formulário de feedback para um site ou app9 min de leitura
Crie um formulário de feedback dentro do app em SvelteKit
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.
Coloque o formulário no layout que envolve as suas telas logadas e envie-o para uma única action nomeada, /feedback?/send, com use:enhance. Um hook handle coloca o usuário em event.locals, então a própria action adiciona o id e o e-mail dele e lê a página da requisição. O navegador envia só a nota, o comentário e o id da rota.
A aba SvelteKit do guia principal envia JSON para um endpoint +server.ts a partir de qualquer página, com login ou não. Esta página monta a versão para dentro de um app: uma form action de verdade que funciona antes do JavaScript carregar, com a identidade do usuário tirada da sessão no servidor. Ela foi escrita para SvelteKit 3 e Svelte 5. Chaves e domínios permitidos estão no guia do SvelteKit na documentação.
Por que uma action para todas as telas
Form actions pertencem a uma página, mas um formulário de feedback fica num layout e aparece em dezenas de páginas. Copiar uma action para cada +page.server.ts seria uma bagunça. O SvelteKit deixa um formulário chamar a action de outra rota com uma query string, então toda tela envia para /feedback?/send e um arquivo concentra a lógica. Cada valor vem então do lugar certo:
| Valor | Origem | Confiança |
|---|---|---|
rating, comment | O formulário | O que o usuário quer dizer |
user_id, email | event.locals.user, definido pelo hook handle | Lido do cookie de sessão no servidor |
page | O Referer da requisição da action | Só da mesma origem; qualquer outra coisa é descartada |
screen | page.route.id num input oculto | Um rótulo de agrupamento. Conferido contra um padrão antes de ser armazenado |
page.route.id é o caminho do arquivo da rota, como /(app)/projects/[id]/settings. Todas as páginas de configurações de projeto o compartilham, que é o que você quer ao tirar a média das notas por tela. A action remove segmentos de grupo como (app), já que são um detalhe da sua estrutura de pastas.
- O usuário avalia a tela no formulário do layout. use:enhance o envia para /feedback?/send sem carregar a página.
- O hook handle roda primeiro, lê o cookie de sessão e coloca o usuário em event.locals.
- A action send confere se há um usuário e uma nota inteira de 1 a 5. Se não, devolve fail com uma mensagem.
- Ela adiciona o id e o e-mail do usuário, a página do Referer e o id da rota limpo, e envia com a chave secreta.
- update com navigate como false aplica o resultado na tela atual, então a frase de agradecimento aparece onde o usuário está.
Configuração do projeto
src/
app.d.ts
env.ts
hooks.server.ts
lib/
FeedbackForm.svelte
server/session.ts
routes/
(app)/+layout.svelte
feedback/
+page.server.ts
+page.svelte
.envCrie uma chave secreta com o escopo submissions:write em API keys. O SvelteKit 3 declara variáveis de ambiente em src/env.ts. As variáveis são privadas e lidas em runtime, a menos que você diga o contrário, então a chave nunca chega a um bundle do navegador e pode mudar no seu host sem rebuild. O .env a preenche durante o desenvolvimento.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyimport { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});Cole esta definição na aba Fields do formulário. O formulário mostra a nota e o comentário; a action fornece 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 ou um campo que a definição não lista, e a prévia mostra o 422 que a action transforma em mensagem.Construa
Coloque o usuário em locals
handle roda em toda requisição que o servidor recebe, incluindo envios de form actions. Tipe o usuário uma vez em app.d.ts e depois leia-o da sua sessão no hook. readSession representa o que o seu código de auth já faz com o cookie.
declare global {
namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}
}
export {};import type { Handle } from "@sveltejs/kit/hooks";
import { readSession } from "#lib/server/session.js";
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = await readSession(event.cookies.get("session"));
return resolve(event);
};A form action
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const prerender = false;
const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;
function pageFrom(referer: string | null, origin: string) {
try {
const url = new URL(referer ?? "");
return url.origin === origin ? url.origin + url.pathname : "";
} catch {
return "";
}
}
export const actions = {
send: async ({ request, locals, url }) => {
const data = await request.formData();
if (!locals.user) return fail(401, { message: "Your session ended. Sign in again, then send it." });
const rating = Number(data.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return fail(400, { message: "Pick a score from 1 to 5." });
}
const screen = String(data.get("screen") ?? "").replace(/\/\([^)]+\)/g, "") || "/";
const back = pageFrom(request.headers.get("referer"), url.origin);
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 ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
rating,
comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
page: back,
screen: SCREEN.test(screen) ? screen : "unknown",
user_id: locals.user.id,
email: locals.user.email,
_gotcha: String(data.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return fail(502, { message: "We couldn't send it. Try again in a moment." });
}
if (response.status === 429) return fail(429, { message: "Too many answers at once. Try again in a minute." });
const result = await response.json().catch(() => null);
if (!result?.ok) {
return fail(400, { message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it." });
}
return { sent: true, back };
},
} satisfies Actions;faildevolve um status e dados sem lançar erro. No SvelteKit 3, um formulário com enhance também recebe esse status, então401e400aparecem na aba de rede como o que são.pageFrommantém umReferersó quando ele é da sua própria origem, e descarta a query string.export const prerender = falseestá ali de propósito. Uma página com actions não pode ser pré-renderizada, então se o seu layout raiz pré-renderiza por padrão, o build falha sem isso.- A linha
Originrepassa a origem do navegador, com a do próprio app como fallback, e diz ao Ikiform de onde veio a requisição. É isso que faz os testes locais contarem comolocalhost, abaixo. - Um
429do Ikiform ganha mensagem própria, porque atrás de um servidor ele significa que o formulário como um todo está ocupado, não este usuário.
O formulário no layout
<script lang="ts">
import { enhance } from "$app/forms";
import { page } from "$app/state";
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
let sending = $state(false);
</script>
{#if page.form?.sent}
<p role="status">Thanks. We read every one.</p>
{:else}
<form
method="POST"
action="/feedback?/send"
aria-busy={sending}
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ navigate: false, refreshAll: false });
sending = false;
};
}}
>
<fieldset>
<legend>How is this screen working for you?</legend>
{#each scores as label, index}
<label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
{/each}
</fieldset>
<label for="feedback-comment">What would make it better?</label>
<textarea id="feedback-comment" name="comment" rows="4" maxlength="4000"></textarea>
<input type="hidden" name="screen" value={page.route.id ?? ""} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if page.form?.message}
<p role="alert">{page.form.message}</p>
{/if}
<button disabled={sending}>{sending ? "Sending" : "Send feedback"}</button>
</form>
{/if}- Por padrão, um formulário com enhance cuja action está em outra página navega até ela depois do envio.
navigate: falsemantém o usuário na tela dele e coloca o resultado empage.formaqui. refreshAll: falseevita rodar de novo todas as funçõesloaddepois de um sucesso. O feedback não muda nada do que a página mostra.- Numa falha o formulário não é resetado, então o comentário fica na caixa ao lado da mensagem.
page.formpertence à página atual. Quando o usuário navega, ele é limpo, e a próxima tela mostra um formulário novo.
<script lang="ts">
import FeedbackForm from "#lib/FeedbackForm.svelte";
let { children } = $props();
</script>
<main>{@render children()}</main>
<aside>
<FeedbackForm />
</aside>Sem JavaScript, o mesmo formulário continua funcionando. O navegador envia para /feedback?/send e cai em /feedback, que renderiza o resultado e um link de volta para a página de origem:
<script lang="ts">
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
</script>
{#if form?.sent}
<p role="status">Thanks. We read every one.</p>
<a href={form.back || "/"}>Back to where you were</a>
{:else if form?.message}
<p role="alert">{form.message}</p>
<a href="/">Back to the app</a>
{:else}
<p>Send feedback from the panel on any screen of the app.</p>
{/if}Honeypot, verificação humana e limites
O input oculto _gotcha passa intacto, e o Ikiform armazena um preenchido como spam. A verificação humana do Ikiform é pulada em envios com chave secreta. Aqui a action já recusa quem não tem sessão, o que barra os bots que uma verificação humana pararia. O que sobra é uma conta logada enviando em loop, então defina um preset de limite de taxa para um formulário que envia a partir de um único 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.
Renderização e deploy
As telas em volta do formulário podem ser renderizadas no servidor ou pré-renderizadas como você quiser; o formulário em si não precisa de nada na renderização. Só /feedback precisa rodar num servidor. Os adapters são definidos em vite.config.ts, dentro do plugin sveltekit().
| Host | Adapter e chave |
|---|---|
| Vercel | @sveltejs/adapter-vercel. Adicione IKIFORM_SECRET_KEY em Environment Variables. O edge runtime não existe mais no SvelteKit 3, então a action roda numa função Node. |
| Netlify | @sveltejs/adapter-netlify. Defina a chave nas variáveis de ambiente do site. |
| Cloudflare | @sveltejs/adapter-cloudflare com Wrangler 4.118 ou posterior, sua peer dependency. Guarde a chave como secret do Worker, por exemplo com wrangler secret put IKIFORM_SECRET_KEY, e leia-a via $app/env/private como acima. |
Teste no localhost de graça
Sem plano, um workspace recebe 50 envios de teste por mês a partir de localhost. O Ikiform decide isso pelo header Origin, e um fetch do seu servidor não envia nenhum a menos que você o defina. Por isso a action repassa o Origin da requisição: no vite dev ele é http://localhost:5173, e o envio conta como teste gratuito. Sem ele, os testes locais voltam com 402 plan_required, retidos até você escolher um plano.
Confira se funciona
- Rode
vite dev, faça login, abra a tela de configurações de um projeto e envie uma nota 2 com um comentário. - Abra Submissions.
screenmostra/projects/[id]/settings,pagetem o id real do projeto, euser_ideemailbatem com a sua conta. - Apague o cookie de sessão e envie de novo. O formulário mostra a mensagem de login e mantém o seu comentário.
- Desligue o JavaScript e envie. Você cai em
/feedbackcom a frase de agradecimento e um link de volta para a tela. - Responda ao e-mail de notificação. Ele vai para o usuário logado.
Pegadinhas
| Problema | O que fazer |
|---|---|
O agradecimento aparece em /feedback em vez da tela atual | O callback do enhance chama update() sem navigate: false, ou o formulário não tem enhance. Confira o bloco use:enhance. |
locals.user é sempre null na action | O hook lê outro nome de cookie, ou o path do cookie não cobre /feedback. O SvelteKit 3 usa / como padrão para cookies novos, mas cookies definidos por código mais antigo podem estar restritos a um subcaminho. |
| Um envio de outro domínio seu é recusado | O SvelteKit sempre confere a origem dos envios de formulário. Adicione essa origem a csrf.trustedOrigins em vez de abrir para todo mundo. |
O build falha com um erro sobre pré-renderizar /feedback | Uma página com actions não pode ser pré-renderizada. Mantenha export const prerender = false no +page.server.ts dela. |
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
Sim. Defina o action do formulário como a outra rota mais o nome da action, como /feedback?/send. Com use:enhance, passe navigate: false para o update, para que o resultado apareça na página atual em vez de navegar até lá.
Leia a sessão no hook handle e defina event.locals.user. Toda action recebe locals, então ela pode anexar o id e o e-mail do usuário sem confiar em nada que o navegador envie.
Páginas com form actions não podem ser pré-renderizadas, porque um servidor precisa responder ao POST. Defina prerender como false na rota que contém a action.
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.