Feedback form for a website or app7 min read
Build an in-app feedback form in Astro
An in-app Astro feedback form: an action that takes the user from middleware locals, the page from the request and the screen from the route pattern.
Write an Astro action with accept: "form" that validates the rating with Zod, reads the signed-in user from context.locals, and forwards the answer to your form backend with a secret from astro:env. Render the form with its action set to the action, so it posts without JavaScript, and add a short script that calls the same action in place when JavaScript is there. The page the answer is about comes from the request's Referer, and Astro.routePattern names the screen.
The main guide's Astro tab asks any visitor how useful a page was. This page puts feedback inside an Astro app with signed-in users, where the server should decide who wrote it and the form only asks for a score and a comment. For keys and form setup, the Astro docs guide has the details.
Who fills in what
Astro actions are public endpoints, reachable at a URL named after the action. Anyone can call sendFeedback with a crafted body, so the action can't believe a user id it's given. Split the values by who can be trusted to supply them:
| Value | Set by | How |
|---|---|---|
rating, comment | The user | Validated by the action's Zod schema |
user_id, email | Middleware | context.locals.user, from the session cookie on every request |
page | The request | The Referer header, which is the page the form sits on |
screen | The page's render | Astro.routePattern, such as /app/projects/[id]/settings, in a hidden input |
routePattern is the reason Astro is pleasant here. It names the file that rendered the page, so every project's settings screen lands under one label while page keeps the real address. It travels through the browser, so the schema checks its shape and replaces anything odd with unknown.
- Without JavaScript, the browser posts the form to the current page, with the action named in the URL.
- Middleware runs first on every request and puts the signed-in user on context.locals.
- The action validates the rating and screen, adds the user and the page, and posts to Ikiform with the secret.
- Astro renders the page again, and Astro.getActionResult() shows the thank-you line or the error.
- With JavaScript, the script calls the same action over fetch. Middleware and the handler run exactly as before.
- The script swaps the form for the thank-you line, or writes the error under it, with no reload.
Project setup
src/
actions/index.ts
components/FeedbackForm.astro
middleware.ts
env.d.ts
pages/app/projects/[id]/settings.astro
astro.config.mjs
.envDeclare the secret in the astro:env schema. Astro then refuses to start without it, types the import, and keeps it out of the client bundle. Any server adapter works; Node is shown.
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" }),
},
},
});Put IKIFORM_SECRET_KEY=ik_sk_... in .env for local work. Create the key under API keys with the submissions:write scope. Then paste this definition on the form's Fields tab:
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
Build it
Put the user on locals
Middleware runs before every page and action. Read your session there once, with whatever your auth code provides, and every handler gets the result. readSession stands in for that code. Astro's own sessions work too: read context.session in the action instead.
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;
}
}The 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.",
});
},
}),
};- The handler's second argument is the action context. It has
locals,requestandcookies, but noredirect, so a missing session becomes anUNAUTHORIZEDerror the form can show. .catch("unknown")onscreenturns a forged label intounknowninstead of failing the whole answer.- Empty form inputs reach a
formaction asnull, which is whycommentand the honeypot arenullish. - Both the form post and the script's call are sent by the page, so
Refererholds its address either way.pageFromkeeps the origin and path. Originis passed on so local tests count aslocalhost. More on that below.
The form component
---
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}makes the form work with no JavaScript at all. Astro handles the post and renders the page with the result.isInputErrorseparates schema failures, which havefields, from errors the handler threw, such as the session message.- The script imports
actionsfromastro:actionsand calls the same handler with the form's data. Astro bundles it, so there's no extra file. - A no-JavaScript submit leaves the page as a POST result, and a refresh asks to resend. If that bothers you,
getActionContextin middleware lets you store the result and redirect.
The page
---
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>Rendering mode
A form action only works on a page rendered on demand. Prerendered pages are built once, so there's no request to post to. With the default static output, mark each app page with export const prerender = false, or set output: "server" if most of the site is behind sign-in. Middleware still runs on prerendered pages at build time, where there's no cookie, so locals.user is null there.
Honeypot, human check and limits
The hidden _gotcha input passes through, and a filled one is stored as spam. The human check never runs for a secret-key post, and the sign-in requirement does that job instead. Astro checks the Origin of form posts to on-demand pages by default, so another site can't submit the form in a user's browser.
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.
Deploy
| Host | Adapter | The secret |
|---|---|---|
| Vercel | @astrojs/vercel | Add IKIFORM_SECRET_KEY in the project's environment variables. If you use Astro sessions, configure a storage driver; Vercel has no default. |
| Netlify | @astrojs/netlify | Add it in the site's environment variables. Sessions default to Netlify Blobs. |
| Cloudflare | @astrojs/cloudflare | Run npx wrangler secret put IKIFORM_SECRET_KEY and put it in .dev.vars locally. astro:env/server reads it, as does env from cloudflare:workers. Sessions use a KV binding named SESSION. |
Since Astro 6, Astro.locals.runtime.env is gone from the Cloudflare adapter. Code that read the key from there needs the astro:env import above.
Free local tests
A workspace with no plan gets 50 free test submissions a month from localhost, and Ikiform recognises those by the Origin header. The browser sends http://localhost:4321 to astro dev, but the action's own fetch to Ikiform sends no origin. The Origin line forwards the browser's, so local tests are free, and in production the header is your own domain.
Check it works
- Run
astro dev, sign in, open a project's settings page and send a 2 with a comment. - In Submissions, check
screenis/app/projects/[id]/settings,pagehas the real id, anduser_idandemailare yours. - Turn JavaScript off and submit again. The page reloads with the thank-you line.
- Delete the session cookie and submit. The form shows the sign-in message.
- Reply to the notification email. It's addressed to the signed-in user.
Gotchas
| Problem | What to do |
|---|---|
| The form posts but the page shows no result | The page is prerendered. Add export const prerender = false or switch to output: "server". |
IKIFORM_SECRET_KEY is missing when the build starts | Secrets are validated whenever astro:env/server is imported. Provide the variable to the build, or a placeholder there and the real value at runtime. |
402 plan_required on local tests | The forwarded Origin is empty or not localhost. Open the app at localhost:4321 and keep the Origin line. |
locals.user is always null in production | The cookie name or domain differs between environments, or the page is still prerendered. Log context.cookies.has("session") in middleware. |
422 validation_failed | The post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id. |
Questions
Set it on context.locals in src/middleware.ts, then read context.locals.user from the handler's second argument. Astro sessions are the other route, through context.session.
Yes, on pages rendered on demand. Set the form's action to the action and its method to POST, then read the outcome with Astro.getActionResult() in the page.
Render Astro.routePattern into a hidden input. It gives the route file's pattern, such as /app/projects/[id]/settings, so answers group by screen instead of by id.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.