Feedback form for a website or app

Feedback form for a website or app8 min read

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.

Preet SutharFounder, Ikiform

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

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:

ValueSourceTrust
rating, commentThe formWhat the user means to say
user_id, emailevent.locals.user, set by the handle hookRead from the session cookie on the server
pageThe Referer of the action requestSame-origin only; anything else is dropped
screenpage.route.id in a hidden inputA 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.

How a SvelteKit feedback post goes from a layout form through one form actionlayoutformanyscreen,use:enhancehandlehooksessiontolocals.user?/sendactionchecksuserandscoreIkiformsecretkey,storesitpage.formthanksonthesamescreenfail(401or400)messageundertheformPOST /feedbacklocals+ user, pagenavigate: falseno user or bad scoreHow a SvelteKit feedback post goes from a layout form through one form actionlayout formany screen,use:enhancehandle hooksession to locals.user?/sendactionchecks user andscoreIkiformsecret key, stores itpage.formthanks on the samescreenfail(401or 400)messageunder theformPOST /feedbacklocals+ user, pagenavigate: falseno user or bad score
  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
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
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
src/env.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.

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.

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

export {};
src/hooks.server.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
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
<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
<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
<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 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.

HostAdapter 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

ProblemWhat to do
The thank-you shows on /feedback instead of the current screenThe 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 actionThe 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 refusedSvelteKit 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 /feedbackA page with actions can't be prerendered. Keep export const prerender = false in its +page.server.ts.
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.