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

Published 2026-10-25 · https://ikiform.com/guides/feedback-form/astro

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

Diagram: Two ways one Astro feedback form reaches the same action

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

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

```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" }),
    },
  },
});
```

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:

Form: In-app feedback

- **How is this screen working for you?** (`rating`, rating, required)
- **What would make it better?** (`comment`, textarea)
- **Page** (`page`, url)
- **Screen** (`screen`, text)
- **User** (`user_id`, text)
- **Email** (`email`, email)

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

```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;
  }
}
```

### The 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.",
      });
    },
  }),
};
```

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

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

```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](https://ikiform.com/docs/rate-limits.md) 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

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

| 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

### How do I get the logged-in user inside an Astro action?

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.

### Do Astro actions work without JavaScript?

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.

### How do I know which screen feedback came from in Astro?

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.

### Is Ikiform free?

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.

## More guides

- [Feedback form for a website or app](https://ikiform.com/guides/feedback-form.md): Put a feedback form on any page or inside your app: a rating, a comment, the page it came from, spam filtering, and alerts when a score is low.
- [SvelteKit feedback form](https://ikiform.com/guides/feedback-form/sveltekit.md): An in-app SvelteKit feedback form: a layout form posts to one form action, which adds the user from locals and the page from the request on the server.
- [Nuxt feedback form](https://ikiform.com/guides/feedback-form/nuxt.md): An in-app Nuxt feedback form: a server route that reads the user session, takes the page from Referer and still works when JavaScript hasn't loaded.
- [Next.js feedback form](https://ikiform.com/guides/feedback-form/nextjs.md): An in-app Next.js feedback form: a server action that reads the session, takes the page from the request and forwards a rating with the user attached.
- [Bug report form](https://ikiform.com/guides/feedback-form/bug-report.md): A bug report form that asks people for steps and severity, and lets the page add the URL, browser, viewport and recent errors on its own.
- [Feature request form](https://ikiform.com/guides/feedback-form/feature-request.md): A feature request form that asks for the problem instead of the solution, stores requests by theme, counts people not rows, and emails them when it ships.
