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.
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:
| 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.
- After hydration, the component stops the native submit and posts the fields as JSON with $fetch.
- The server route checks the Origin, reads the user from the session, validates the score and adds the page from Referer.
- It forwards the answer to Ikiform with the secret key from runtimeConfig.
- A JSON request gets { sent: true } or an error with a message, and the form updates in place.
- Before hydration, the browser posts the form itself. The route runs the same checks and the same post.
- Then it answers with a 303 back to the page, with the outcome in the query string for the component to show.
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.
app/
components/FeedbackForm.vue
pages/projects/[id]/settings.vue
server/
api/feedback.post.ts
shared/
types/auth.d.ts
nuxt.config.ts
.envDeclare 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.
export default defineNuxtConfig({
modules: ["nuxt-auth-utils"],
runtimeConfig: {
ikiformSecretKey: "",
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-stringCreate 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:
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.
6 shows the 422 the route would get back.Build it
The server route
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.tssuffix makes the file answerPOSTonly. - The Origin check runs before anything else.
readBody, whichreadValidatedBodyuses, also parses urlencoded bodies, so without it another site could post a plain form to this route with your user's cookie attached. getUserSessiondoesn't throw. The route picks the answer itself: a401for$fetch, a redirect for a plain post.requireUserSessionwould send a401page to a user without JavaScript.z.coerce.number()turns the urlencoded"4"into4..catch("unknown")stores a malformedscreenasunknowninstead of refusing the whole answer.backkeeps only the path ofReferer, so a redirect can never leave your site.
The component
<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>methodandactionare real attributes, so before hydration the browser posts the form to the route. Once Vue is running,@submit.preventtakes over.- The JSON body lets the route know it can answer with JSON. The body comes from
FormData, so the honeypot andscreentravel without extra code. - On a failed
$fetch, the error'sdata.messageis the message fromcreateErrorin 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. useUserSessionhides 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
| 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
- Run
nuxt dev, sign in, open a project's settings page and send a score of 2 with a comment. - In Submissions, check that
user_idandemailmatch your account,screenisprojects-id-settingsandpageholds the real path. - Turn JavaScript off and send again. The page reloads with the thank-you line and
?feedback=sentin the address. - Post to
/api/feedbackwithcurland noOrigin. The route answers403and nothing reaches Ikiform. - 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
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.
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.
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.
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.