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

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

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

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

Diagrama: Como um envio de feedback no SvelteKit vai de um formulário no layout até uma única form action

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

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

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`src/env.ts`:

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

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)

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

```ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
```

`src/hooks.server.ts`:

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

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

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

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

```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](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 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

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

| 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

### Um formulário do SvelteKit pode chamar uma action de outra rota?

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

### Como pegar o usuário logado dentro de uma form action do SvelteKit?

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.

### Por que minha rota de feedback quebra o build de pré-renderização?

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.

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