# Build an in-app feedback form in Nuxt

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

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

Post a two-question form to a Nuxt server route at `server/api/feedback.post.ts`. The route reads the signed-in user with `getUserSession` from nuxt-auth-utils, takes the page from the request's `Referer`, validates the score with Zod and forwards it all to your form backend with a secret key kept in `runtimeConfig`. With JavaScript the form posts through `$fetch` and stays in place; without it, the same route redirects back with the result in the URL.

The [Nuxt tab in the main guide](https://ikiform.com/guides/feedback-form.md#form) builds page feedback for anyone who visits. Inside a Nuxt app with accounts, the job is narrower and the data is richer: the user is signed in, the server knows who they are, and the router knows which screen they're on. This page builds that version. Keys, allowed domains and the rest of the product setup are in the [Nuxt docs guide](https://ikiform.com/docs/guides/nuxt.md).

## What a signed-in Nuxt app changes

In a public form the browser decides what gets stored. Here, the browser only decides the score and the comment. Everything that says who and where is added by the server route, where nobody can edit it:

| Field | Set by | How |
| --- | --- | --- |
| `rating`, `comment` | The user | Radio buttons and a text box |
| `user_id`, `email` | The server route | `getUserSession(event)` reads the sealed session cookie |
| `page` | The server route | The `Referer` header, which the browser sends on same-origin posts with the full path |
| `screen` | The component, checked by the route | `useRoute().name`, such as `projects-id-settings`, built by Nuxt from the page's file path |

Route names are a free grouping key in Nuxt. Every project's settings page shares `projects-id-settings`, so you can average scores per screen while `page` keeps the exact address.

Diagram: How one Nuxt server route handles feedback with and without JavaScript

1. After hydration, the component stops the native submit and posts the fields as JSON with $fetch.
2. The server route checks the Origin, reads the user from the session, validates the score and adds the page from Referer.
3. It forwards the answer to Ikiform with the secret key from runtimeConfig.
4. A JSON request gets { sent: true } or an error with a message, and the form updates in place.
5. Before hydration, the browser posts the form itself. The route runs the same checks and the same post.
6. Then it answers with a 303 back to the page, with the outcome in the query string for the component to show.

The route tells the two apart by the request's content type. Both paths store exactly the same answer.

## Project setup

This uses Nuxt 4's layout, with app code under `app/` and server code at the root. Install `nuxt-auth-utils` and `zod`. If your app already signs people in some other way, keep it and read the user from your own session in the route instead.

`Project tree`:

```text
app/
  components/FeedbackForm.vue
  pages/projects/[id]/settings.vue
server/
  api/feedback.post.ts
shared/
  types/auth.d.ts
nuxt.config.ts
.env
```

Declare the secret key in `runtimeConfig` with an empty default. Nuxt fills it at runtime from `NUXT_IKIFORM_SECRET_KEY`, and because the key isn't under `public`, it exists only on the server.

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-string
```

Create the key under **API keys** with the `submissions:write` scope. nuxt-auth-utils seals its session cookie with `NUXT_SESSION_PASSWORD`, which must be at least 32 characters; in development it writes one for you on first run. Tell it what your user looks like, so `session.user.id` is typed:

`shared/types/auth.d.ts`:

```ts
declare module "#auth-utils" {
  interface User {
    id: string;
    email: string;
  }
}

export {};
```

Then paste the definition on the form's **Fields** tab. The first two fields are the form; the server route adds 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)

The preview runs Ikiform's validator, so a score of `6` shows the `422` the route would get back.

## Build it

### The server route

`server/api/feedback.post.ts`:

```ts
import { z } from "zod";

const Feedback = z.object({
  rating: z.coerce.number().int().min(1).max(5),
  comment: z.string().trim().max(4000).default(""),
  screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
  _gotcha: z.string().default(""),
});

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

export default defineEventHandler(async (event) => {
  const own = getRequestURL(event, { xForwardedHost: true });
  if (getRequestHeader(event, "origin") !== own.origin) {
    throw createError({ status: 403, message: "Posts from other sites are refused." });
  }

  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
  const referer = getRequestHeader(event, "referer");
  const back = (outcome: string) => {
    const target = new URL(referer ?? "/", own.origin);
    return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
  };

  const session = await getUserSession(event);
  if (!session.user) {
    if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
    return back("signed-out");
  }

  const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
  if (!parsed.success) {
    if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
    return back("invalid");
  }

  const { ikiformSecretKey } = useRuntimeConfig(event);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      Origin: own.origin,
    },
    body: JSON.stringify({
      ...parsed.data,
      page: pageFrom(referer),
      user_id: session.user.id,
      email: session.user.email,
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return wantsJson ? { sent: true } : back("sent");
  if (wantsJson) {
    throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
  }
  return back("failed");
});
```

- The `.post.ts` suffix makes the file answer `POST` only.
- The Origin check runs before anything else. `readBody`, which `readValidatedBody` uses, also parses urlencoded bodies, so without it another site could post a plain form to this route with your user's cookie attached.
- `getUserSession` doesn't throw. The route picks the answer itself: a `401` for `$fetch`, a redirect for a plain post. `requireUserSession` would send a `401` page to a user without JavaScript.
- `z.coerce.number()` turns the urlencoded `"4"` into `4`. `.catch("unknown")` stores a malformed `screen` as `unknown` instead of refusing the whole answer.
- `back` keeps only the path of `Referer`, so a redirect can never leave your site.

### The component

`app/components/FeedbackForm.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  "signed-out": "Your session ended. Sign in again, then send it.",
  invalid: "Pick a score from 1 to 5.",
  failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");

async function send(event: SubmitEvent) {
  const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/feedback", { method: "POST", body });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
    status.value = "idle";
  }
}
</script>

<template>
  <template v-if="loggedIn">
    <p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
    <form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
      <fieldset>
        <legend>How is this screen working for you?</legend>
        <label v-for="(label, index) in SCORES" :key="label">
          <input type="radio" name="rating" :value="index + 1" required /> {{ label }}
        </label>
      </fieldset>
      <label for="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000" />
      <input type="hidden" name="screen" :value="screen" />
      <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
      <p v-if="error" role="alert">{{ error }}</p>
      <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
    </form>
  </template>
</template>
```

- `method` and `action` are real attributes, so before hydration the browser posts the form to the route. Once Vue is running, `@submit.prevent` takes over.
- The JSON body lets the route know it can answer with JSON. The body comes from `FormData`, so the honeypot and `screen` travel without extra code.
- On a failed `$fetch`, the error's `data.message` is the message from `createError` in the route.
- After a plain post, `?feedback=` in the URL sets the first state, so the thank-you or the error shows on the reloaded page.
- `useUserSession` hides the form from signed-out visitors. It's a convenience, not a check; the route does the checking.

Put `<FeedbackForm />` in any page under the signed-in part of your app, such as `app/pages/projects/[id]/settings.vue`. Nuxt auto-imports it.

## Honeypot, human check and limits

The `_gotcha` input reaches Ikiform with the rest of the fields, and a filled one is stored as spam. Ikiform's human check is skipped for posts with a secret key, and here the session does that job: the route refuses anyone who isn't signed in. What remains is a signed-in account posting in a loop, which Ikiform can only count by your server's address.

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 choices

The form needs server rendering for its no-JavaScript result. On a route with `ssr: false` in `routeRules`, the page is blank until JavaScript runs, so the plain post never happens and `?feedback=` is read only in the browser. That's fine, it only removes the fallback. Don't put the form on a `prerender: true` route: the HTML is built once, so `useUserSession` sees nobody and the form is missing until hydration.

## Deploy

| Host | What to do |
| --- | --- |
| Vercel | Nitro detects Vercel, so no preset is needed. Add `NUXT_IKIFORM_SECRET_KEY` and `NUXT_SESSION_PASSWORD` under **Environment Variables** and redeploy. |
| Netlify | Detected too. Add both variables in the site's environment settings with the Functions scope; the server route runs as a function. |
| Cloudflare | Build with the `cloudflare_module` preset for Workers, or connect the repo to Pages, which is detected. Store both values as secrets, and put them in `.dev.vars` for `wrangler dev`. |

On Cloudflare, environment values exist only while a request runs. The route reads `useRuntimeConfig(event)` inside the handler, which is where it has to be.

## Test it on localhost for free

Without a plan, a workspace accepts 50 test submissions a month from `localhost`, judged by the `Origin` header. A `fetch` from a Nitro route has no browser behind it and sends no `Origin` unless you set one. That's why the route passes `own.origin` on: under `nuxt dev` it's `http://localhost:3000` and counts as a test, and in production it's your domain. Leave it out and every local test comes back `402 plan_required`.

## Check it works

1. Run `nuxt dev`, sign in, open a project's settings page and send a score of 2 with a comment.
2. In **Submissions**, check that `user_id` and `email` match your account, `screen` is `projects-id-settings` and `page` holds the real path.
3. Turn JavaScript off and send again. The page reloads with the thank-you line and `?feedback=sent` in the address.
4. Post to `/api/feedback` with `curl` and no `Origin`. The route answers `403` and nothing reaches Ikiform.
5. Clear the session cookie and send from the open page. The form shows the sign-in message.

## Gotchas

| Problem | What to do |
| --- | --- |
| `403` from your own route in production | A proxy changes the host, so `getRequestURL` disagrees with the browser's `Origin`. Make the proxy send `X-Forwarded-Host`, which `xForwardedHost: true` reads. |
| `screen` is `unknown` on every answer | Route names with a locale suffix, such as those from `@nuxtjs/i18n`, contain characters the pattern refuses. Widen the regex, or strip the suffix in the component. |
| The secret key is empty in production | The variable name must be `NUXT_` plus the key in upper snake case, `NUXT_IKIFORM_SECRET_KEY`, and the key must exist in `runtimeConfig`. |
| `402 plan_required` on local tests | The post left without an `Origin`. Keep the `Origin: own.origin` header and open the app at `localhost`. |
| `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 get the logged-in user in a Nuxt server route?

With nuxt-auth-utils, call getUserSession(event) or requireUserSession(event) inside the handler and read session.user. Without it, set event.context.user in a server middleware and read that. Never take the user from the request body.

### Can a Nuxt form work without JavaScript?

Yes. Give the form a real method and action pointing at a server route, and have the route redirect back with a 303 when the request isn't JSON. After hydration, intercept submit and use $fetch.

### How does Nuxt read NUXT_ environment variables?

Declare the key in runtimeConfig in nuxt.config, and Nuxt overrides it at runtime from an environment variable named NUXT_ plus the key in upper snake case. Keys outside public stay on the server.

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