Feedback form for a website or app

Feedback form for a website or app8 min read

Build an in-app feedback form in Next.js

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.

Preet SutharFounder, Ikiform

Put a small client form with a 1 to 5 rating on the screens you want feedback on, and point it at a server action. The action reads the signed-in user from your session cookie, takes the page from the request's Referer, and forwards everything to your form backend with a secret key. The browser never sends who the user is, so nobody can file feedback as someone else.

The main guide's Next.js tab is a page feedback form for any visitor. This page is for the inside of your app, where people are signed in. There you already know who is writing and which screen they're on, so the form asks two questions and the server fills in the rest. Product setup, keys and allowed domains are in the Next.js docs guide.

What changes when the user is signed in

A public feedback form takes what the browser gives it. An in-app form shouldn't. If the browser sends user_id, anyone can change it in the dev tools and send feedback as a colleague or a customer. Each value should come from the place that can be trusted with it:

ValueComes fromWhy there
rating, commentThe formOnly the user knows them
user_id, emailThe session cookie, read in the actionThe browser can't forge a signed session
pageThe Referer header of the action's requestA server action posts to the page that renders it, so the header holds the full URL
screenA prop the page passes in, such as projects/[id]/settingsGroups every project's settings page under one name, so averages mean something

screen is the one value that passes through the browser. It's a label for grouping, not a permission, and the action checks its shape before it's stored.

How a Next.js feedback answer picks up the user and the page on the serverFeedbackFormrating,comment,screenserveractionPOSTtothecurrentpagegetUser()readsthesessioncookieIkiformsecretkey,storesitactionstatethanks,ortheerrorerrorstatesigninagain,commentkeptFormDatacookies()+ user, pageresultno sessionHow a Next.js feedback answer picks up the user and the page on the serverFeedbackFormrating, comment,screenserver actionPOST to the currentpagegetUser()reads thesession cookieIkiformsecret key, stores itaction statethanks, or the errorerrorstatesign inagain,comment keptFormDatacookies()+ user, pageresultno session
  1. The user picks a score and maybe writes a comment. The form sends them with the screen name.
  2. The server action runs as a POST to the page the form is on, with the session cookie and the page in Referer.
  3. getUser() reads and checks the session cookie. With no valid session, nothing is posted.
  4. The action adds the user's id, email and the page address, and posts to Ikiform with the secret key.
  5. Ikiform's answer becomes the action state: the thank-you line, or the error with the comment still typed in.
The session is read when the form is sent, not when the page renders. The page around the form can stay static.

Project setup

Four files, one of which you probably have already. The App Router layout is the usual one:

Project tree
app/
  (app)/
    projects/[id]/settings/page.tsx
  feedback/
    actions.ts
    feedback-form.tsx
lib/
  user.ts
.env.local

Create a secret key under API keys with the submissions:write scope and put it in .env.local. Never give it a NEXT_PUBLIC_ prefix, which would ship it to every browser.

.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses

On the form's Fields tab, paste this definition. Users see the first two fields; your server sets the other four. Because there's an email field, Reply on the notification goes straight 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.

Try a score of 0 or an unknown field name and the preview answers with the same 422 your action gets back.

Build it

Read the user in one place

Next.js recommends a data access layer: one server-only function that reads and checks the session, used by every action. React's cache makes repeated calls in one request read the cookie once. decrypt is whatever your session code already has; with an auth library, return its user here instead.

lib/user.ts
import "server-only";

import { cookies } from "next/headers";
import { cache } from "react";
import { decrypt } from "@/lib/session";

export interface User {
  id: string;
  email: string;
}

export const getUser = cache(async (): Promise<User | null> => {
  const token = (await cookies()).get("session")?.value;
  if (!token) return null;
  const session = await decrypt(token);
  return session?.userId ? { id: session.userId, email: session.email } : null;
});

The server action

app/feedback/actions.ts
"use server";

import { headers } from "next/headers";
import { getUser } from "@/lib/user";

export interface FeedbackState {
  status: "idle" | "sent" | "error";
  message: string;
  comment?: string;
}

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

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

export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
  const comment = String(formData.get("comment") ?? "").trim().slice(0, 4000);
  const user = await getUser();
  if (!user) return { status: "error", message: "Your session ended. Sign in again, then send it.", comment };

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

  const request = await headers();
  const screen = String(formData.get("screen") ?? "");

  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 ${process.env.IKIFORM_SECRET_KEY}`,
        Origin: request.get("origin") ?? "",
      },
      body: JSON.stringify({
        rating,
        comment,
        page: pageFrom(request.get("referer")),
        screen: SCREEN.test(screen) ? screen : "unknown",
        user_id: user.id,
        email: user.email,
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't send it. Try again in a moment.", comment };
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We read every one." };
  return {
    status: "error",
    message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it. Try again.",
    comment,
  };
}
  • The session check comes first and doesn't throw. A user whose session expired mid-comment gets a message and keeps the comment, not an error page.
  • pageFrom keeps only the origin and path of Referer. Query strings in an app often hold ids and tokens you don't want in a feedback inbox.
  • screen must match a short path-like pattern. Anything else is stored as unknown, so a crafted post can't fill the column with junk.
  • Origin passes the browser's origin on. In production it's your domain; on your machine it's localhost, which is what makes local tests free, as explained below.
  • AbortSignal.timeout keeps the button from spinning forever when the network is slow.

The form

app/feedback/feedback-form.tsx
"use client";

import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";

const initial: FeedbackState = { status: "idle", message: "" };
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

export function FeedbackForm({ screen }: { screen: string }) {
  const [state, action, pending] = useActionState(sendFeedback, initial);

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action} aria-busy={pending}>
      <fieldset>
        <legend>How is this screen working for you?</legend>
        {SCORES.map((label, index) => (
          <label key={label}>
            <input type="radio" name="rating" value={index + 1} required /> {label}
          </label>
        ))}
      </fieldset>
      <label htmlFor="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows={4} maxLength={4000} defaultValue={state.comment} />
      <input type="hidden" name="screen" value={screen} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
  • The action goes straight to action. Next.js renders it as a real form post, so a user who clicks before the JavaScript loads still gets their feedback sent.
  • pending from useActionState disables the button while the action runs, which stops double sends.
  • React resets the form after an action. defaultValue={state.comment} puts the comment back when there's an error, so nobody types it twice.
  • The labels name the scores. Broken and Great say more than 1 and 5, and the stored value is still a number.

Put it on a screen

app/(app)/projects/[id]/settings/page.tsx
import { FeedbackForm } from "@/app/feedback/feedback-form";

export default function ProjectSettingsPage() {
  return (
    <main>
      <h1>Project settings</h1>
      <aside>
        <FeedbackForm screen="projects/[id]/settings" />
      </aside>
    </main>
  );
}

Pass the route's folder path as screen. Every project's settings page then shares one name, and the full address with the real id is still in page.

Honeypot, human check and limits

The hidden _gotcha input is forwarded as is. Ikiform stores a filled one as spam. The human check doesn't run, because a post with a secret key skips it, and that's fine here: the action refuses anyone without a session, which a bot doesn't have. The risk left is one signed-in account sending in a loop. Ikiform's rate limit sees only your server's address, so pick a preset that fits.

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 caching

The form reads nothing at render time. It doesn't call cookies() or headers(), so it doesn't make the page dynamic, and with Cache Components on it can sit in the static shell without a <Suspense> boundary. Only the action touches the request, and actions always run per request. If the page already reads the session for other reasons, nothing changes.

Deploy

HostWhat to do
VercelAdd IKIFORM_SECRET_KEY under the project's Environment Variables for Production and Preview. Actions run as functions with no extra setup.
NetlifyNetlify builds Next.js with its own integration. Add the key in the site's environment variables with the Functions scope, then redeploy.
CloudflareCloudflare runs Next.js on Workers through its OpenNext integration. Store the key as a Worker secret, and put it in .dev.vars for local previews of the Worker build.

Behind a proxy or CDN that changes the host, Next.js rejects actions whose Origin doesn't match. List that domain in experimental.serverActions.allowedOrigins in next.config.ts.

Test it on localhost for free

A workspace without a plan receives 50 test submissions a month, but only from localhost, which Ikiform reads from the Origin header. A server-side fetch sends no Origin of its own, so without the line that passes it on, every local test would come back 402 plan_required and be held. With it, next dev on http://localhost:3000 counts as a local test, and production posts carry your real domain.

Check it works

  1. Run next dev, sign in, open a project's settings and send a score of 2 with a comment.
  2. Open Submissions. The answer has user_id, email, screen set to projects/[id]/settings, and page with the real project id and no query string.
  3. Sign out in another tab, then send again. The form shows the sign-in message with your comment still in the box.
  4. Disable JavaScript and send once more. The page reloads with the thank-you line.
  5. Reply to the notification email. It goes to the signed-in user's address.

Gotchas

ProblemWhat to do
402 plan_required on every local testThe post reached Ikiform without a localhost origin. Check the Origin line in the action, and open the app at localhost, not your machine's network address.
page is emptyA strict Referrer-Policy such as no-referrer removes the header, even on same-origin requests. Use strict-origin-when-cross-origin, the default.
Failed to find Server Action, right after a deployA tab still runs the previous build, whose action id no longer exists. Ask the user to reload, and keep NEXT_SERVER_ACTIONS_ENCRYPTION_KEY the same on every instance when you self-host.
403 forbidden from IkiformThe key lacks submissions:write, belongs to another workspace or was revoked. Create a new one.
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.