# Build an in-app feedback form in SvelteKit

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

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

Put the form in the layout that wraps your signed-in screens and post it to a single named action, `/feedback?/send`, with `use:enhance`. A `handle` hook puts the user on `event.locals`, so the action adds their id and email itself and reads the page from the request. The browser sends only the score, the comment and the route id.

The [SvelteKit tab of the main guide](https://ikiform.com/guides/feedback-form.md#form) posts JSON to a `+server.ts` endpoint from any page, signed in or not. This page builds the version for the inside of an app: a real form action that works before JavaScript loads, with the user's identity taken from the session on the server. It's written for SvelteKit 3 and Svelte 5. Keys and allowed domains are covered in the [SvelteKit docs guide](https://ikiform.com/docs/guides/sveltekit.md).

## Why one action for every screen

Form actions belong to a page, but a feedback form lives in a layout and shows on dozens of pages. Copying an action into each `+page.server.ts` would be a mess. SvelteKit lets a form call another route's action with a query string, so every screen posts to `/feedback?/send` and one file holds the logic. Each value then comes from the right place:

| Value | Source | Trust |
| --- | --- | --- |
| `rating`, `comment` | The form | What the user means to say |
| `user_id`, `email` | `event.locals.user`, set by the `handle` hook | Read from the session cookie on the server |
| `page` | The `Referer` of the action request | Same-origin only; anything else is dropped |
| `screen` | `page.route.id` in a hidden input | A grouping label. Checked against a pattern before it's stored |

`page.route.id` is the route's file path, such as `/(app)/projects/[id]/settings`. All project settings pages share it, which is what you want when you average scores per screen. The action strips group segments like `(app)`, since they're a detail of your folder layout.

Diagram: How a SvelteKit feedback post goes from a layout form through one form action

1. The user rates the screen in the layout's form. use:enhance sends it to /feedback?/send without a page load.
2. The handle hook runs first, reads the session cookie and puts the user on event.locals.
3. The send action checks there is a user and a whole score from 1 to 5. If not, it returns fail with a message.
4. It adds the user's id and email, the page from Referer and the cleaned route id, and posts with the secret key.
5. update with navigate set to false applies the result to the current screen, so the thank-you line shows where the user is.

Without JavaScript, the browser lands on /feedback instead, which shows the same result and a link back.

## Project setup

`Project tree`:

```text
src/
  app.d.ts
  env.ts
  hooks.server.ts
  lib/
    FeedbackForm.svelte
    server/session.ts
  routes/
    (app)/+layout.svelte
    feedback/
      +page.server.ts
      +page.svelte
.env
```

Create a secret key with the `submissions:write` scope under **API keys**. SvelteKit 3 declares environment variables in `src/env.ts`. Variables are private and read at runtime unless you say otherwise, so the key never reaches a browser bundle and can change on your host without a rebuild. `.env` fills it during development.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

Paste this definition on the form's **Fields** tab. The form shows the score and the comment; the action supplies the rest, and the `email` field makes **Reply** on each notification go to the user.

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)

Send a score of `6` or a field the definition doesn't list, and the preview shows the `422` the action turns into a message.

## Build it

### Put the user on locals

`handle` runs for every request the server receives, form action posts included. Type the user once in `app.d.ts`, then read it from your session in the hook. `readSession` stands for whatever your auth code already does with the cookie.

`src/app.d.ts`:

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

export {};
```

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";
import { readSession } from "#lib/server/session.js";

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.user = await readSession(event.cookies.get("session"));
  return resolve(event);
};
```

### The form action

`src/routes/feedback/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const prerender = false;

const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;

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

export const actions = {
  send: async ({ request, locals, url }) => {
    const data = await request.formData();
    if (!locals.user) return fail(401, { message: "Your session ended. Sign in again, then send it." });

    const rating = Number(data.get("rating"));
    if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
      return fail(400, { message: "Pick a score from 1 to 5." });
    }

    const screen = String(data.get("screen") ?? "").replace(/\/\([^)]+\)/g, "") || "/";
    const back = pageFrom(request.headers.get("referer"), url.origin);

    let response: Response;
    try {
      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: request.headers.get("origin") ?? url.origin,
        },
        body: JSON.stringify({
          rating,
          comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
          page: back,
          screen: SCREEN.test(screen) ? screen : "unknown",
          user_id: locals.user.id,
          email: locals.user.email,
          _gotcha: String(data.get("_gotcha") ?? ""),
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch {
      return fail(502, { message: "We couldn't send it. Try again in a moment." });
    }

    if (response.status === 429) return fail(429, { message: "Too many answers at once. Try again in a minute." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(400, { message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it." });
    }
    return { sent: true, back };
  },
} satisfies Actions;
```

- `fail` returns a status and data without throwing. In SvelteKit 3 an enhanced form receives that status too, so `401` and `400` show up in the network tab as what they are.
- `pageFrom` keeps a `Referer` only when it's from your own origin, and drops the query string.
- `export const prerender = false` is there on purpose. A page with actions can't be prerendered, so if your root layout prerenders by default the build fails without it.
- The `Origin` line passes the browser's origin on, falling back to the app's own, and tells Ikiform where the request came from. That's what makes local tests count as `localhost`, below.
- A `429` from Ikiform gets its own message, because behind a server it means the form as a whole is busy, not this user.

### The layout form

`src/lib/FeedbackForm.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { page } from "$app/state";

  const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
  let sending = $state(false);
</script>

{#if page.form?.sent}
  <p role="status">Thanks. We read every one.</p>
{:else}
  <form
    method="POST"
    action="/feedback?/send"
    aria-busy={sending}
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ navigate: false, refreshAll: false });
        sending = false;
      };
    }}
  >
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {#each scores as label, index}
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      {/each}
    </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={page.route.id ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if page.form?.message}
      <p role="alert">{page.form.message}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send feedback"}</button>
  </form>
{/if}
```

- By default, an enhanced form whose action is on another page navigates there after the post. `navigate: false` keeps the user on their screen and puts the result in `page.form` here.
- `refreshAll: false` skips rerunning every `load` function after a success. Feedback changes nothing the page shows.
- On a failure the form isn't reset, so the comment stays in the box next to the message.
- `page.form` belongs to the current page. When the user navigates, it clears, and the next screen shows a fresh form.

`src/routes/(app)/+layout.svelte`:

```svelte
<script lang="ts">
  import FeedbackForm from "#lib/FeedbackForm.svelte";

  let { children } = $props();
</script>

<main>{@render children()}</main>
<aside>
  <FeedbackForm />
</aside>
```

Without JavaScript the same form still works. The browser posts to `/feedback?/send` and lands on `/feedback`, which renders the result and a link back to the page it came from:

`src/routes/feedback/+page.svelte`:

```svelte
<script lang="ts">
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

{#if form?.sent}
  <p role="status">Thanks. We read every one.</p>
  <a href={form.back || "/"}>Back to where you were</a>
{:else if form?.message}
  <p role="alert">{form.message}</p>
  <a href="/">Back to the app</a>
{:else}
  <p>Send feedback from the panel on any screen of the app.</p>
{/if}
```

## Honeypot, human check and limits

The hidden `_gotcha` input goes through untouched, and Ikiform stores a filled one as spam. Ikiform's human check is skipped for secret-key posts. Here the action already refuses anyone without a session, which keeps out the bots a human check would stop. What's left is a signed-in account posting in a loop, so set a rate limit preset for a form that posts from one server.

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.

## Rendering and deploy

The screens around the form can be server-rendered or prerendered as you like; the form itself needs nothing at render time. Only `/feedback` must run on a server. Adapters are set in `vite.config.ts`, inside the `sveltekit()` plugin.

| Host | Adapter and key |
| --- | --- |
| Vercel | `@sveltejs/adapter-vercel`. Add `IKIFORM_SECRET_KEY` under **Environment Variables**. The edge runtime is gone in SvelteKit 3, so the action runs in a Node function. |
| Netlify | `@sveltejs/adapter-netlify`. Set the key in the site's environment variables. |
| Cloudflare | `@sveltejs/adapter-cloudflare` with Wrangler 4.118 or later, its peer dependency. Store the key as a Worker secret, for example with `wrangler secret put IKIFORM_SECRET_KEY`, and read it through `$app/env/private` as above. |

## Test it on localhost for free

Without a plan, a workspace receives 50 test submissions a month from `localhost`. Ikiform decides that from the `Origin` header, and a `fetch` from your server sends none unless you set it. That's why the action passes the request's `Origin`: on `vite dev` it's `http://localhost:5173`, and the post counts as a free test. Leave it out and local tests come back `402 plan_required`, held until you pick a plan.

## Check it works

1. Run `vite dev`, sign in, open a project's settings screen and send a score of 2 with a comment.
2. Open **Submissions**. `screen` reads `/projects/[id]/settings`, `page` has the real project id, and `user_id` and `email` match your account.
3. Clear the session cookie and send again. The form shows the sign-in message and keeps your comment.
4. Turn off JavaScript and send. You land on `/feedback` with the thank-you line and a link back to the screen.
5. Reply to the notification email. It goes to the signed-in user.

## Gotchas

| Problem | What to do |
| --- | --- |
| The thank-you shows on `/feedback` instead of the current screen | The enhance callback calls `update()` without `navigate: false`, or the form isn't enhanced. Check the `use:enhance` block. |
| `locals.user` is always `null` in the action | The hook reads a different cookie name, or the cookie's `path` doesn't cover `/feedback`. SvelteKit 3 defaults new cookies to `/`, but cookies set by older code may be scoped to a subpath. |
| A post from another domain you own is refused | SvelteKit always checks the origin of form posts. Add that origin to `csrf.trustedOrigins` rather than opening it to everyone. |
| The build fails with an error about prerendering `/feedback` | A page with actions can't be prerendered. Keep `export const prerender = false` in its `+page.server.ts`. |
| `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

### Can a SvelteKit form call an action on a different route?

Yes. Set the form's action to the other route plus the action name, such as /feedback?/send. With use:enhance, pass navigate: false to update so the result shows on the current page instead of navigating there.

### How do I get the logged-in user inside a SvelteKit form action?

Read the session in the handle hook and set event.locals.user. Every action receives locals, so it can attach the user's id and email without trusting anything the browser sends.

### Why does my feedback route break the prerender build?

Pages with form actions can't be prerendered, because a server has to answer the POST. Set prerender to false on the route that holds the action.

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