웹사이트나 앱용 피드백 폼6분 분량
Nuxt로 인앱 피드백 폼 만들기
Nuxt 인앱 피드백 폼입니다. 서버 라우트가 사용자 세션을 읽고 Referer에서 페이지를 가져오며, JavaScript가 로드되지 않았을 때도 동작합니다.
질문 두 개짜리 폼을 server/api/feedback.post.ts의 Nuxt 서버 라우트로 POST하세요. 라우트는 nuxt-auth-utils의 getUserSession으로 로그인한 사용자를 읽고, 요청의 Referer에서 페이지를 가져오고, Zod로 점수를 검증한 뒤 runtimeConfig에 둔 시크릿 키로 모두 폼 백엔드에 전달합니다. JavaScript가 있으면 폼은 $fetch로 POST하고 그 자리에 머물며, 없으면 같은 라우트가 결과를 URL에 담아 원래 페이지로 리디렉션합니다.
메인 가이드의 Nuxt 탭은 방문하는 누구에게서나 페이지 피드백을 받습니다. 계정이 있는 Nuxt 앱 안에서는 할 일이 좁아지고 데이터는 풍부해집니다. 사용자가 로그인해 있고, 서버는 그가 누구인지 알고, 라우터는 어느 화면에 있는지 압니다. 이 페이지는 그 버전을 만듭니다. 키, 허용 도메인 등 나머지 제품 설정은 Nuxt 문서 가이드에 있습니다.
로그인한 Nuxt 앱에서 달라지는 점
공개 폼에서는 무엇이 저장될지 브라우저가 정합니다. 여기서 브라우저가 정하는 것은 점수와 코멘트뿐입니다. 누가, 어디서인지를 말하는 모든 것은 아무도 편집할 수 없는 서버 라우트가 추가합니다.
| 필드 | 설정 주체 | 방법 |
|---|---|---|
rating, comment | 사용자 | 라디오 버튼과 텍스트 상자 |
user_id, email | 서버 라우트 | getUserSession(event)가 봉인된 세션 쿠키를 읽음 |
page | 서버 라우트 | 브라우저가 같은 origin POST에 전체 경로와 함께 보내는 Referer 헤더 |
screen | 컴포넌트, 라우트가 확인 | 페이지 파일 경로로 Nuxt가 만드는 useRoute().name, 예: projects-id-settings |
Nuxt에서 라우트 이름은 공짜로 얻는 그룹 키입니다. 모든 프로젝트의 설정 페이지가 projects-id-settings를 공유하므로, page에 정확한 주소를 남기면서 화면별로 점수 평균을 낼 수 있습니다.
- 하이드레이션 후에는 컴포넌트가 기본 제출을 막고 $fetch로 필드를 JSON으로 POST합니다.
- 서버 라우트가 Origin을 확인하고, 세션에서 사용자를 읽고, 점수를 검증하고, Referer의 페이지를 추가합니다.
- runtimeConfig의 시크릿 키로 답변을 Ikiform에 전달합니다.
- JSON 요청은 { sent: true }나 메시지가 담긴 오류를 받고, 폼이 그 자리에서 업데이트됩니다.
- 하이드레이션 전에는 브라우저가 직접 폼을 POST합니다. 라우트는 같은 확인과 같은 POST를 실행합니다.
- 그런 다음 결과를 쿼리 문자열에 담아 303으로 원래 페이지로 돌려보내고, 컴포넌트가 이를 표시합니다.
프로젝트 설정
Nuxt 4의 구조를 써서 앱 코드는 app/ 아래, 서버 코드는 루트에 둡니다. nuxt-auth-utils와 zod를 설치하세요. 앱에서 이미 다른 방식으로 로그인을 처리한다면 그대로 두고 라우트에서 자체 세션으로부터 사용자를 읽으세요.
app/
components/FeedbackForm.vue
pages/projects/[id]/settings.vue
server/
api/feedback.post.ts
shared/
types/auth.d.ts
nuxt.config.ts
.envruntimeConfig에 빈 기본값으로 시크릿 키를 선언하세요. Nuxt가 런타임에 NUXT_IKIFORM_SECRET_KEY에서 값을 채우며, 키가 public 아래에 있지 않으므로 서버에만 존재합니다.
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-stringAPI keys에서 submissions:write 스코프로 키를 만드세요. nuxt-auth-utils는 최소 32자인 NUXT_SESSION_PASSWORD로 세션 쿠키를 봉인하며, 개발 중에는 첫 실행 시 자동으로 하나 써 줍니다. session.user.id에 타입이 붙도록 사용자 형태를 알려 주세요.
declare module "#auth-utils" {
interface User {
id: string;
email: string;
}
}
export {};그런 다음 폼의 Fields 탭에 정의를 붙여 넣으세요. 처음 두 필드가 폼이고 나머지는 서버 라우트가 추가하며, email 필드 덕분에 각 알림의 Reply가 사용자에게 갑니다.
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
6을 넣으면 라우트가 돌려받을 422가 표시됩니다.만들기
서버 라우트
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");
});.post.ts접미사는 파일이POST에만 응답하게 합니다.- Origin 확인이 가장 먼저 실행됩니다.
readValidatedBody가 쓰는readBody는 urlencoded 본문도 파싱하므로, 이 확인이 없으면 다른 사이트가 사용자의 쿠키가 붙은 일반 폼을 이 라우트로 POST할 수 있습니다. getUserSession은 예외를 던지지 않습니다. 응답은 라우트가 직접 고릅니다.$fetch에는401, 일반 POST에는 리디렉션입니다.requireUserSession은 JavaScript가 없는 사용자에게401페이지를 보냅니다.z.coerce.number()는 urlencoded"4"를4로 바꿉니다..catch("unknown")은 형식이 잘못된screen을 답변 전체를 거부하지 않고unknown으로 저장합니다.back은Referer의 경로만 남기므로 리디렉션이 사이트 밖으로 나갈 수 없습니다.
컴포넌트
<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와action은 실제 속성이므로 하이드레이션 전에는 브라우저가 폼을 라우트로 POST합니다. Vue가 실행되면@submit.prevent가 넘겨받습니다.- JSON 본문을 보고 라우트는 JSON으로 응답해도 된다는 것을 압니다. 본문은
FormData에서 만들므로 허니팟과screen이 추가 코드 없이 전달됩니다. $fetch가 실패하면 오류의data.message는 라우트의createError가 보낸 메시지입니다.- 일반 POST 후에는 URL의
?feedback=가 초기 상태를 설정하므로, 새로고침된 페이지에 감사 문구나 오류가 표시됩니다. useUserSession은 로그아웃한 방문자에게 폼을 숨깁니다. 편의 기능일 뿐 검증이 아니며, 검증은 라우트가 합니다.
app/pages/projects/[id]/settings.vue처럼 앱의 로그인 영역에 있는 아무 페이지에나 <FeedbackForm />을 넣으세요. Nuxt가 자동으로 import합니다.
허니팟, 사람 확인, 한도
_gotcha 입력은 다른 필드와 함께 Ikiform에 도달하고, 값이 채워지면 스팸으로 저장됩니다. 시크릿 키 POST에서는 Ikiform의 사람 확인을 건너뛰며, 여기서는 세션이 그 역할을 합니다. 라우트가 로그인하지 않은 사람을 모두 거부합니다. 남은 것은 로그인한 계정이 반복해서 POST하는 경우이며, Ikiform은 이를 서버의 주소로만 셀 수 있습니다.
서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.
렌더링 선택
JavaScript 없이 결과를 보여 주려면 폼에 서버 렌더링이 필요합니다. routeRules에서 ssr: false인 라우트는 JavaScript가 실행될 때까지 페이지가 비어 있으므로 일반 POST가 일어나지 않고 ?feedback=도 브라우저에서만 읽힙니다. 대체 경로가 사라질 뿐이니 괜찮습니다. prerender: true 라우트에는 폼을 두지 마세요. HTML이 한 번만 빌드되므로 useUserSession이 아무도 보지 못하고, 하이드레이션 전까지 폼이 없습니다.
배포
| 호스트 | 해결 방법 |
|---|---|
| Vercel | Nitro가 Vercel을 감지하므로 프리셋이 필요 없습니다. Environment Variables에 NUXT_IKIFORM_SECRET_KEY와 NUXT_SESSION_PASSWORD를 추가하고 다시 배포하세요. |
| Netlify | 이것도 감지됩니다. 사이트의 환경 설정에 Functions 스코프로 두 변수를 추가하세요. 서버 라우트는 함수로 실행됩니다. |
| Cloudflare | Workers용 cloudflare_module 프리셋으로 빌드하거나, 감지되는 Pages에 저장소를 연결하세요. 두 값을 시크릿으로 저장하고, wrangler dev용으로 .dev.vars에 넣으세요. |
Cloudflare에서 환경 값은 요청이 실행되는 동안에만 존재합니다. 라우트는 핸들러 안에서 useRuntimeConfig(event)를 읽으며, 반드시 거기서 읽어야 합니다.
localhost에서 무료로 테스트하기
플랜이 없으면 워크스페이스는 localhost에서 월 50건의 테스트 제출을 받으며, Origin 헤더로 판단합니다. Nitro 라우트의 fetch에는 브라우저가 없으므로 직접 설정하지 않으면 Origin을 보내지 않습니다. 그래서 라우트가 own.origin을 전달합니다. nuxt dev에서는 http://localhost:3000이므로 테스트로 집계되고, 프로덕션에서는 내 도메인입니다. 빼면 모든 로컬 테스트가 402 plan_required로 돌아옵니다.
작동 확인하기
nuxt dev를 실행하고 로그인한 뒤 프로젝트 설정 페이지를 열어 코멘트와 함께 점수 2를 보내세요.- Submissions에서
user_id와email이 내 계정과 일치하고,screen이projects-id-settings이며,page에 실제 경로가 있는지 확인하세요. - JavaScript를 끄고 다시 보내세요. 페이지가 새로고침되며 감사 문구가 표시되고 주소에
?feedback=sent가 붙습니다. Origin없이curl로/api/feedback에 POST하세요. 라우트가403으로 응답하고 아무것도 Ikiform에 도달하지 않습니다.- 세션 쿠키를 지우고 열린 페이지에서 보내세요. 폼에 로그인 메시지가 표시됩니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
프로덕션에서 내 라우트가 403을 반환함 | 프록시가 호스트를 바꿔서 getRequestURL이 브라우저의 Origin과 다릅니다. 프록시가 X-Forwarded-Host를 보내게 하세요. xForwardedHost: true가 이를 읽습니다. |
모든 답변에서 screen이 unknown | @nuxtjs/i18n이 만드는 것처럼 로케일 접미사가 붙은 라우트 이름에는 패턴이 거부하는 문자가 들어 있습니다. 정규식을 넓히거나 컴포넌트에서 접미사를 떼어 내세요. |
| 프로덕션에서 시크릿 키가 비어 있음 | 변수 이름은 NUXT_ 뒤에 키를 대문자 snake case로 붙인 NUXT_IKIFORM_SECRET_KEY여야 하며, 키가 runtimeConfig에 있어야 합니다. |
로컬 테스트에서 402 plan_required | POST가 Origin 없이 나갔습니다. Origin: own.origin 헤더를 유지하고 localhost로 앱을 여세요. |
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요. |
질문
nuxt-auth-utils를 쓴다면 핸들러 안에서 getUserSession(event)나 requireUserSession(event)를 호출하고 session.user를 읽으세요. 쓰지 않는다면 서버 미들웨어에서 event.context.user를 설정하고 그것을 읽으세요. 요청 본문에서 사용자를 가져오지 마세요.
네. 폼에 서버 라우트를 가리키는 실제 method와 action을 주고, 요청이 JSON이 아니면 라우트가 303으로 원래 페이지로 리디렉션하게 하세요. 하이드레이션 후에는 submit을 가로채 $fetch를 쓰세요.
nuxt.config의 runtimeConfig에 키를 선언하면, Nuxt가 런타임에 NUXT_ 뒤에 키를 대문자 snake case로 붙인 환경 변수로 값을 덮어씁니다. public 밖의 키는 서버에만 남습니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.