Formulário de feedback para um site ou app8 min de leitura
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.
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 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 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.
- Sem JavaScript, o navegador envia o formulário para a página atual, com a action nomeada na URL.
- O middleware roda primeiro em toda requisição e coloca o usuário logado em context.locals.
- A action valida a avaliação e a tela, adiciona o usuário e a página e envia ao Ikiform com o secret.
- O Astro renderiza a página de novo, e Astro.getActionResult() mostra a frase de agradecimento ou o erro.
- Com JavaScript, o script chama a mesma action via fetch. O middleware e o handler rodam exatamente como antes.
- O script troca o formulário pela frase de agradecimento, ou escreve o erro abaixo dele, sem recarregar.
Configuração do projeto
src/
actions/index.ts
components/FeedbackForm.astro
middleware.ts
env.d.ts
pages/app/projects/[id]/settings.astro
astro.config.mjs
.envDeclare 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.
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:
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
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.
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();
});declare namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}A action
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,requestecookies, mas nãoredirect, então uma sessão ausente vira um erroUNAUTHORIZEDque o formulário consegue mostrar. .catch("unknown")emscreentransforma um rótulo forjado emunknownem vez de derrubar a resposta inteira.- Inputs vazios do formulário chegam a uma action
formcomonull, e por issocommente o honeypot sãonullish. - Tanto o envio do formulário quanto a chamada do script são feitos pela página, então
Referertraz o endereço dela em ambos os casos.pageFrommantém a origem e o caminho. Originé repassado para que os testes locais contem comolocalhost. Mais sobre isso abaixo.
O componente do formulário
---
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.isInputErrorsepara falhas do schema, que têmfields, de erros que o handler lançou, como a mensagem de sessão.- O script importa
actionsdeastro:actionse 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,
getActionContextno middleware permite guardar o resultado e redirecionar.
A página
---
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 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
- Rode
astro dev, faça login, abra a página de configurações de um projeto e envie um 2 com um comentário. - Em Submissions, confira que
screené/app/projects/[id]/settings, quepagetem o id real e queuser_ideemailsão os seus. - Desligue o JavaScript e envie de novo. A página recarrega com a frase de agradecimento.
- Apague o cookie de sessão e envie. O formulário mostra a mensagem de login.
- 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
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.
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.
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.
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.