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.
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:
| Value | Comes from | Why there |
|---|---|---|
rating, comment | The form | Only the user knows them |
user_id, email | The session cookie, read in the action | The browser can't forge a signed session |
page | The Referer header of the action's request | A server action posts to the page that renders it, so the header holds the full URL |
screen | A prop the page passes in, such as projects/[id]/settings | Groups 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.
- The user picks a score and maybe writes a comment. The form sends them with the screen name.
- The server action runs as a POST to the page the form is on, with the session cookie and the page in Referer.
- getUser() reads and checks the session cookie. With no valid session, nothing is posted.
- The action adds the user's id, email and the page address, and posts to Ikiform with the secret key.
- Ikiform's answer becomes the action state: the thank-you line, or the error with the comment still typed in.
Project setup
Four files, one of which you probably have already. The App Router layout is the usual one:
app/
(app)/
projects/[id]/settings/page.tsx
feedback/
actions.ts
feedback-form.tsx
lib/
user.ts
.env.localCreate 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-usesOn 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.
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.
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
"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.
pageFromkeeps only the origin and path ofReferer. Query strings in an app often hold ids and tokens you don't want in a feedback inbox.screenmust match a short path-like pattern. Anything else is stored asunknown, so a crafted post can't fill the column with junk.Originpasses the browser's origin on. In production it's your domain; on your machine it'slocalhost, which is what makes local tests free, as explained below.AbortSignal.timeoutkeeps the button from spinning forever when the network is slow.
The form
"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. pendingfromuseActionStatedisables 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.
BrokenandGreatsay more than1and5, and the stored value is still a number.
Put it on a screen
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
| Host | What to do |
|---|---|
| Vercel | Add IKIFORM_SECRET_KEY under the project's Environment Variables for Production and Preview. Actions run as functions with no extra setup. |
| Netlify | Netlify builds Next.js with its own integration. Add the key in the site's environment variables with the Functions scope, then redeploy. |
| Cloudflare | Cloudflare 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
- Run
next dev, sign in, open a project's settings and send a score of 2 with a comment. - Open Submissions. The answer has
user_id,email,screenset toprojects/[id]/settings, andpagewith the real project id and no query string. - Sign out in another tab, then send again. The form shows the sign-in message with your comment still in the box.
- Disable JavaScript and send once more. The page reloads with the thank-you line.
- Reply to the notification email. It goes to the signed-in user's address.
Gotchas
| Problem | What to do |
|---|---|
402 plan_required on every local test | The 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 empty | A 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 deploy | A 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 Ikiform | The key lacks submissions:write, belongs to another workspace or was revoked. Create a new one. |
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
Read the session inside the server action, not in the browser. Call your session or auth helper there and add the user's id and email to the post. Anything the browser sends about identity can be changed by the user.
A server action is a POST to the page that renders the form, so its Referer header holds that page's URL. Keep the origin and path and drop the query string.
Not this one. The form reads no cookies or headers while it renders. Only the action reads the session, and it runs on each submit.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.