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

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

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

Escreva uma Astro action com `accept: "form"` que valida a avaliação com Zod, lê o usuário logado de `context.locals` e encaminha a resposta ao seu backend de formulários com um secret de `astro:env`. Renderize o formulário com o `action` apontando para a action, para que ele envie sem JavaScript, e adicione um script curto que chama a mesma action no lugar quando há JavaScript. A página de que a resposta trata vem do `Referer` da requisição, e `Astro.routePattern` dá nome à tela.

A [aba Astro do guia principal](https://ikiform.com/pt-br/guides/feedback-form.md#form) pergunta a qualquer visitante quão útil foi uma página. Esta página coloca o feedback dentro de um app Astro com usuários logados, onde o servidor deve decidir quem escreveu e o formulário só pede uma nota e um comentário. Para chaves e configuração do formulário, o [guia do Astro na documentação](https://ikiform.com/pt-br/docs/guides/astro.md) tem os detalhes.

## Quem preenche o quê

Astro actions são endpoints públicos, acessíveis numa URL com o nome da action. Qualquer um pode chamar `sendFeedback` com um corpo forjado, então a action não pode acreditar num id de usuário que recebe. Separe os valores por quem é confiável para fornecê-los:

| Valor | Definido por | Como |
| --- | --- | --- |
| `rating`, `comment` | O usuário | Validados pelo schema Zod da action |
| `user_id`, `email` | Middleware | `context.locals.user`, a partir do cookie de sessão em toda requisição |
| `page` | A requisição | O header `Referer`, que é a página onde o formulário está |
| `screen` | A renderização da página | `Astro.routePattern`, como `/app/projects/[id]/settings`, num input oculto |

`routePattern` é o motivo pelo qual o Astro é agradável aqui. Ele dá nome ao arquivo que renderizou a página, então a tela de configurações de todos os projetos cai sob um rótulo, enquanto `page` mantém o endereço real. Ele passa pelo navegador, então o schema confere o formato e troca qualquer coisa estranha por `unknown`.

Diagrama: Dois caminhos pelos quais um formulário de feedback em Astro chega à mesma action

1. Sem JavaScript, o navegador envia o formulário para a página atual, com a action nomeada na URL.
2. O middleware roda primeiro em toda requisição e coloca o usuário logado em context.locals.
3. A action valida a avaliação e a tela, adiciona o usuário e a página e envia ao Ikiform com o secret.
4. O Astro renderiza a página de novo, e Astro.getActionResult() mostra a frase de agradecimento ou o erro.
5. Com JavaScript, o script chama a mesma action via fetch. O middleware e o handler rodam exatamente como antes.
6. O script troca o formulário pela frase de agradecimento, ou escreve o erro abaixo dele, sem recarregar.

Um handler, dois transportes. As duas requisições saem da página, então ambas levam o cookie de sessão e a página no Referer.

## Configuração do projeto

`Árvore do projeto`:

```text
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env
```

Declare o secret no schema do `astro:env`. Aí o Astro se recusa a iniciar sem ele, tipa o import e o mantém fora do bundle do cliente. Qualquer adapter de servidor funciona; o exemplo usa Node.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

Coloque `IKIFORM_SECRET_KEY=ik_sk_...` em `.env` para o trabalho local. Crie a chave em **API keys** com o escopo `submissions:write`. Depois cole esta definição na aba **Fields** do formulá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)

O schema da action espelha esses limites, então a maior parte da entrada inválida é recusada antes de custar uma requisição.

## Construa

### Coloque o usuário em locals

O middleware roda antes de toda página e action. Leia a sessão ali uma vez, com o que o seu código de auth oferecer, e todo handler recebe o resultado. `readSession` representa esse código. As sessões do próprio Astro também funcionam: leia `context.session` na action.

`src/middleware.ts`:

```ts
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await readSession(context.cookies.get("session")?.value);
  return next();
});
```

`src/env.d.ts`:

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

### A action

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

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

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
      comment: z.string().max(4000).nullish(),
      screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });

      const origin = context.request.headers.get("origin");
      const 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 ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: input.rating,
          comment: input.comment ?? "",
          page: pageFrom(context.request.headers.get("referer")),
          screen: input.screen,
          user_id: user.id,
          email: user.email,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { sent: true };
      throw new ActionError({
        code: "BAD_GATEWAY",
        message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
      });
    },
  }),
};
```

- O segundo argumento do handler é o contexto da action. Ele tem `locals`, `request` e `cookies`, mas não `redirect`, então uma sessão ausente vira um erro `UNAUTHORIZED` que o formulário consegue mostrar.
- `.catch("unknown")` em `screen` transforma um rótulo forjado em `unknown` em vez de derrubar a resposta inteira.
- Inputs vazios do formulário chegam a uma action `form` como `null`, e por isso `comment` e o honeypot são `nullish`.
- Tanto o envio do formulário quanto a chamada do script são feitos pela página, então `Referer` traz o endereço dela em ambos os casos. `pageFrom` mantém a origem e o caminho.
- `Origin` é repassado para que os testes locais contem como `localhost`. Mais sobre isso abaixo.

### O componente do formulário

`src/components/FeedbackForm.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---

{result?.data?.sent ? (
  <p role="status">Thanks. We read every one.</p>
) : (
  <form method="POST" action={actions.sendFeedback} data-feedback>
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {scores.map((label, index) => (
        <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"></textarea>
    <input type="hidden" name="screen" value={Astro.routePattern} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">
      {result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
    </p>
    <button>Send feedback</button>
  </form>
)}

<script>
  import { actions } from "astro:actions";

  const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
  form?.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button")!;
    const alert = form.querySelector("[role=alert]")!;
    button.disabled = true;
    button.textContent = "Sending";
    const { error } = await actions.sendFeedback(new FormData(form));
    if (!error) {
      form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
      return;
    }
    alert.textContent = error.message;
    button.disabled = false;
    button.textContent = "Send feedback";
  });
</script>
```

- `action={actions.sendFeedback}` faz o formulário funcionar sem nenhum JavaScript. O Astro cuida do envio e renderiza a página com o resultado.
- `isInputError` separa falhas do schema, que têm `fields`, de erros que o handler lançou, como a mensagem de sessão.
- O script importa `actions` de `astro:actions` e chama o mesmo handler com os dados do formulário. O Astro faz o bundle, então não há arquivo extra.
- Um envio sem JavaScript deixa a página como resultado de um POST, e um refresh pede para reenviar. Se isso incomodar, `getActionContext` no middleware permite guardar o resultado e redirecionar.

### A página

`src/pages/app/projects/[id]/settings.astro`:

```astro
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";

export const prerender = false;

if (!Astro.locals.user) return Astro.redirect("/sign-in");
---

<AppLayout title="Project settings">
  <h1>Project settings</h1>
  <aside>
    <FeedbackForm />
  </aside>
</AppLayout>
```

## Modo de renderização

Uma form action só funciona numa página renderizada sob demanda. Páginas pré-renderizadas são geradas uma vez, então não há requisição para onde enviar. Com a saída estática padrão, marque cada página do app com `export const prerender = false`, ou defina `output: "server"` se a maior parte do site fica atrás de login. O middleware ainda roda nas páginas pré-renderizadas no build, onde não há cookie, então `locals.user` é `null` ali.

## Honeypot, verificação humana e limites

O input oculto `_gotcha` passa adiante, e um preenchido é armazenado como spam. A verificação humana nunca roda num envio com chave secreta, e a exigência de login faz esse papel. O Astro confere o `Origin` dos envios de formulário para páginas sob demanda por padrão, então outro site não consegue enviar o formulário no navegador de um usuário.

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.

## Deploy

| Host | Adapter | O segredo |
| --- | --- | --- |
| Vercel | `@astrojs/vercel` | Adicione `IKIFORM_SECRET_KEY` nas variáveis de ambiente do projeto. Se você usa sessões do Astro, configure um driver de armazenamento; a Vercel não tem padrão. |
| Netlify | `@astrojs/netlify` | Adicione nas variáveis de ambiente do site. As sessões usam Netlify Blobs por padrão. |
| Cloudflare | `@astrojs/cloudflare` | Rode `npx wrangler secret put IKIFORM_SECRET_KEY` e coloque a chave em `.dev.vars` localmente. `astro:env/server` a lê, assim como `env` de `cloudflare:workers`. As sessões usam um binding KV chamado `SESSION`. |

Desde o Astro 6, `Astro.locals.runtime.env` não existe mais no adapter da Cloudflare. Código que lia a chave dali precisa do import `astro:env` acima.

## Testes locais gratuitos

Um workspace sem plano recebe 50 envios de teste gratuitos por mês a partir de `localhost`, e o Ikiform os reconhece pelo header `Origin`. O navegador envia `http://localhost:4321` ao `astro dev`, mas o `fetch` da própria action ao Ikiform não envia origem. A linha `Origin` repassa a do navegador, então os testes locais saem de graça, e em produção o header é o seu próprio domínio.

## Confira se funciona

1. Rode `astro dev`, faça login, abra a página de configurações de um projeto e envie um 2 com um comentário.
2. Em **Submissions**, confira que `screen` é `/app/projects/[id]/settings`, que `page` tem o id real e que `user_id` e `email` são os seus.
3. Desligue o JavaScript e envie de novo. A página recarrega com a frase de agradecimento.
4. Apague o cookie de sessão e envie. O formulário mostra a mensagem de login.
5. Responda ao e-mail de notificação. Ele vai para o usuário logado.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| O formulário envia, mas a página não mostra resultado | A página está pré-renderizada. Adicione `export const prerender = false` ou mude para `output: "server"`. |
| `IKIFORM_SECRET_KEY is missing` quando o build começa | Os secrets são validados sempre que `astro:env/server` é importado. Forneça a variável ao build, ou um placeholder ali e o valor real em runtime. |
| `402 plan_required` nos testes locais | O `Origin` repassado está vazio ou não é `localhost`. Abra o app em `localhost:4321` e mantenha a linha `Origin`. |
| `locals.user` é sempre `null` em produção | O nome ou o domínio do cookie muda entre ambientes, ou a página ainda está pré-renderizada. Faça log de `context.cookies.has("session")` no middleware. |
| `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 dentro de uma Astro action?

Defina-o em context.locals no src/middleware.ts e leia context.locals.user no segundo argumento do handler. As sessões do Astro são o outro caminho, via context.session.

### Astro actions funcionam sem JavaScript?

Sim, em páginas renderizadas sob demanda. Defina o action do formulário como a action e o method como POST, e leia o resultado com Astro.getActionResult() na página.

### Como saber de qual tela veio o feedback no Astro?

Renderize Astro.routePattern num input oculto. Ele dá o padrão do arquivo de rota, como /app/projects/[id]/settings, para que as respostas sejam agrupadas por tela em vez de por id.

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