Feedback form for a website or app

Feedback form for a website or app8 min read

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.

Preet SutharFounder, Ikiform

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

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:

FieldSet byHow
rating, commentThe userRadio buttons and a text box
user_id, emailThe server routegetUserSession(event) reads the sealed session cookie
pageThe server routeThe Referer header, which the browser sends on same-origin posts with the full path
screenThe component, checked by the routeuseRoute().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.

How one Nuxt server route handles feedback with and without JavaScriptWith JavaScriptWithout JavaScriptFeedbackForm.vue$fetch,JSONbodyfeedback.post.tsorigin,session,ZodIkiformsecretkey,stores it{ sent:true }thank-youreplacesthe formplainformposturlencodedbodyfeedback.post.tssamechecksIkiformsame post303back?feedback=sentPOST+ user, page200POST+ user, pageredirectHow one Nuxt server route handles feedback with and without JavaScriptWith JavaScriptWithout JavaScriptFeedbackForm.vue$fetch, JSON bodyfeedback.post.tsorigin, session, ZodIkiformsecret key, stores it{ sent: true }thank-you replaces the formplain form posturlencoded bodyfeedback.post.tssame checksIkiformsame post303 back?feedback=sentPOST+ user, page200POST+ user, pageredirect
  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
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
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
.env
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
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.

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.

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

HostWhat to do
VercelNitro detects Vercel, so no preset is needed. Add NUXT_IKIFORM_SECRET_KEY and NUXT_SESSION_PASSWORD under Environment Variables and redeploy.
NetlifyDetected too. Add both variables in the site's environment settings with the Functions scope; the server route runs as a function.
CloudflareBuild 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

ProblemWhat to do
403 from your own route in productionA 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 answerRoute 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 productionThe 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 testsThe post left without an Origin. Keep the Origin: own.origin header and open the app at localhost.
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.