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

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

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

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

Diagram: How a Next.js feedback answer picks up the user and the page on the server

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

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

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

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)

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

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

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

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

```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](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 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

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

| 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

### How do I attach the logged-in user to feedback in Next.js?

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.

### How does a Next.js server action know which page the feedback came from?

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.

### Does a feedback form make my Next.js page dynamic?

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.

### 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.
- [React feedback widget](https://ikiform.com/guides/feedback-form/react-feedback-widget.md): A floating feedback button for a React app that opens a small panel, sends the screen, app version and account with each answer, and never loses a draft.
- [React Router feedback form](https://ikiform.com/guides/feedback-form/react-router.md): A React Router 8 feedback form: a resource route action reads the cookie session, a fetcher posts from the layout, and a no-JS post redirects back.
- [TanStack Start feedback form](https://ikiform.com/guides/feedback-form/tanstack-start.md): An in-app TanStack Start feedback form: a POST server function with session middleware, the route id as context, and the page read from request headers.
- [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.
