Feedback form for a website or app

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.

Preet SutharFounder, Ikiform

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:

ValueSet byHow
rating, commentThe userValidated by the action's Zod schema
user_id, emailMiddlewarecontext.locals.user, from the session cookie on every request
pageThe requestThe Referer header, which is the page the form sits on
screenThe page's renderAstro.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.

Two ways one Astro feedback form reaches the same actionno JavaScriptwith the scriptformPOSTto thecurrentpagemiddlewarelocals.userfrom cookiesendFeedbackZod, thenfetchpagere-rendersgetActionResult()actions.sendFeedback()FormDatafrom theformmiddlewaresame cookie,same usersendFeedbacksamehandlerformreplaced{ data } or{ error }page URLresultfetchJSONTwo ways one Astro feedback form reaches the same actionno JavaScriptwith the scriptform POSTto the current pagemiddlewarelocals.user from cookiesendFeedbackZod, then fetchpage re-rendersgetActionResult()actions.sendFeedback()FormData from the formmiddlewaresame cookie, same usersendFeedbacksame handlerform replaced{ data } or { error }page URLresultfetchJSON
  1. Without JavaScript, the browser posts the form to the current page, with the action named in the URL.
  2. Middleware runs first on every request and puts the signed-in user on context.locals.
  3. The action validates the rating and screen, adds the user and the page, and posts to Ikiform with the secret.
  4. Astro renders the page again, and Astro.getActionResult() shows the thank-you line or the error.
  5. With JavaScript, the script calls the same action over fetch. Middleware and the handler run exactly as before.
  6. The script swaps the form for the thank-you line, or writes the error under it, with no reload.
One handler, two transports. Both requests come from the page, so both carry the session cookie and the page in Referer.

Project setup

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

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

astro.config.mjs
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.

How is this screen working for you?

Set by your server from the request.

The schema in the action mirrors these limits, so most bad input is refused before it costs a request.

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.

src/middleware.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
declare namespace App {
  interface Locals {
    user: { id: string; email: string } | null;
  }
}

The action

src/actions/index.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.",
      });
    },
  }),
};
  • The handler's second argument is the action context. It has locals, request and cookies, but no redirect, so a missing session becomes an UNAUTHORIZED error the form can show.
  • .catch("unknown") on screen turns a forged label into unknown instead of failing the whole answer.
  • Empty form inputs reach a form action as null, which is why comment and the honeypot are nullish.
  • Both the form post and the script's call are sent by the page, so Referer holds its address either way. pageFrom keeps the origin and path.
  • Origin is passed on so local tests count as localhost. More on that below.

The form component

src/components/FeedbackForm.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} makes the form work with no JavaScript at all. Astro handles the post and renders the page with the result.
  • isInputError separates schema failures, which have fields, from errors the handler threw, such as the session message.
  • The script imports actions from astro:actions and 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, getActionContext in middleware lets you store the result and redirect.

The page

src/pages/app/projects/[id]/settings.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>

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

HostAdapterThe secret
Vercel@astrojs/vercelAdd IKIFORM_SECRET_KEY in the project's environment variables. If you use Astro sessions, configure a storage driver; Vercel has no default.
Netlify@astrojs/netlifyAdd it in the site's environment variables. Sessions default to Netlify Blobs.
Cloudflare@astrojs/cloudflareRun 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

  1. Run astro dev, sign in, open a project's settings page and send a 2 with a comment.
  2. In Submissions, check screen is /app/projects/[id]/settings, page has the real id, and user_id and email are yours.
  3. Turn JavaScript off and submit again. The page reloads with the thank-you line.
  4. Delete the session cookie and submit. The form shows the sign-in message.
  5. Reply to the notification email. It's addressed to the signed-in user.

Gotchas

ProblemWhat to do
The form posts but the page shows no resultThe page is prerendered. Add export const prerender = false or switch to output: "server".
IKIFORM_SECRET_KEY is missing when the build startsSecrets 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 testsThe forwarded Origin is empty or not localhost. Open the app at localhost:4321 and keep the Origin line.
locals.user is always null in productionThe cookie name or domain differs between environments, or the page is still prerendered. Log context.cookies.has("session") in middleware.
422 validation_failedThe 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.