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

Preet SutharFundador, Ikiform

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:

ValorOrigemConfiança
rating, commentO formulárioO que o usuário quer dizer
user_id, emailevent.locals.user, definido pelo hook handleLido do cookie de sessão no servidor
pageO Referer da requisição da actionSó da mesma origem; qualquer outra coisa é descartada
screenpage.route.id num input ocultoUm 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.

Como um envio de feedback no SvelteKit vai de um formulário no layout até uma única form actionformulárionolayoutqualquertela,use:enhancehookhandlesessãoparalocals.useraction?/sendconfereusuárioenotaIkiformchavesecreta,armazenapage.formobrigadonamesmatelafail(401ou400)mensagemabaixodoformulárioPOST /feedbacklocals+ usuário, páginanavigate: falsesem usuário ou nota inválidaComo um envio de feedback no SvelteKit vai de um formulário no layout até uma única form actionformulário nolayoutqualquer tela,use:enhancehook handlesessão paralocals.useraction?/sendconfere usuárioe notaIkiformchave secreta,armazenapage.formobrigado na mesma telafail(401ou 400)mensagemabaixo doformulárioPOST /feedbacklocals+ usuário, páginanavigate: falsesem usuário ou nota inválida
  1. O usuário avalia a tela no formulário do layout. use:enhance o envia para /feedback?/send sem carregar a página.
  2. O hook handle roda primeiro, lê o cookie de sessão e coloca o usuário em event.locals.
  3. 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.
  4. 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.
  5. update com navigate como false aplica o resultado na tela atual, então a frase de agradecimento aparece onde o usuário está.
Sem JavaScript, o navegador cai em /feedback, que mostra o mesmo resultado e um link de volta.

Configuração do projeto

Árvore 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
.env

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

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
src/env.ts
import { 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.

Como esta tela está funcionando para você?

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

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

src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
src/hooks.server.ts
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

src/routes/feedback/+page.server.ts
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;
  • fail devolve um status e dados sem lançar erro. No SvelteKit 3, um formulário com enhance também recebe esse status, então 401 e 400 aparecem na aba de rede como o que são.
  • pageFrom mantém um Referer só quando ele é da sua própria origem, e descarta a query string.
  • export const prerender = false está 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 Origin repassa 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 como localhost, abaixo.
  • Um 429 do 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

src/lib/FeedbackForm.svelte
<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: false mantém o usuário na tela dele e coloca o resultado em page.form aqui.
  • refreshAll: false evita rodar de novo todas as funções load depois 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.form pertence à página atual. Quando o usuário navega, ele é limpo, e a próxima tela mostra um formulário novo.
src/routes/(app)/+layout.svelte
<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:

src/routes/feedback/+page.svelte
<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().

HostAdapter 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

  1. Rode vite dev, faça login, abra a tela de configurações de um projeto e envie uma nota 2 com um comentário.
  2. Abra Submissions. screen mostra /projects/[id]/settings, page tem o id real do projeto, e user_id e email batem com a sua conta.
  3. 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.
  4. Desligue o JavaScript e envie. Você cai em /feedback com a frase de agradecimento e um link de volta para a tela.
  5. Responda ao e-mail de notificação. Ele vai para o usuário logado.

Pegadinhas

ProblemaO que fazer
O agradecimento aparece em /feedback em vez da tela atualO 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 actionO 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 é recusadoO 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 /feedbackUma página com actions não pode ser pré-renderizada. Mantenha export const prerender = false no +page.server.ts dela.
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.