# Nuxt로 인앱 피드백 폼 만들기

> Nuxt 인앱 피드백 폼입니다. 서버 라우트가 사용자 세션을 읽고 Referer에서 페이지를 가져오며, JavaScript가 로드되지 않았을 때도 동작합니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/feedback-form/nuxt

질문 두 개짜리 폼을 `server/api/feedback.post.ts`의 Nuxt 서버 라우트로 POST하세요. 라우트는 nuxt-auth-utils의 `getUserSession`으로 로그인한 사용자를 읽고, 요청의 `Referer`에서 페이지를 가져오고, Zod로 점수를 검증한 뒤 `runtimeConfig`에 둔 시크릿 키로 모두 폼 백엔드에 전달합니다. JavaScript가 있으면 폼은 `$fetch`로 POST하고 그 자리에 머물며, 없으면 같은 라우트가 결과를 URL에 담아 원래 페이지로 리디렉션합니다.

[메인 가이드의 Nuxt 탭](https://ikiform.com/ko/guides/feedback-form.md#form)은 방문하는 누구에게서나 페이지 피드백을 받습니다. 계정이 있는 Nuxt 앱 안에서는 할 일이 좁아지고 데이터는 풍부해집니다. 사용자가 로그인해 있고, 서버는 그가 누구인지 알고, 라우터는 어느 화면에 있는지 압니다. 이 페이지는 그 버전을 만듭니다. 키, 허용 도메인 등 나머지 제품 설정은 [Nuxt 문서 가이드](https://ikiform.com/ko/docs/guides/nuxt.md)에 있습니다.

## 로그인한 Nuxt 앱에서 달라지는 점

공개 폼에서는 무엇이 저장될지 브라우저가 정합니다. 여기서 브라우저가 정하는 것은 점수와 코멘트뿐입니다. 누가, 어디서인지를 말하는 모든 것은 아무도 편집할 수 없는 서버 라우트가 추가합니다.

| 필드 | 설정 주체 | 방법 |
| --- | --- | --- |
| `rating`, `comment` | 사용자 | 라디오 버튼과 텍스트 상자 |
| `user_id`, `email` | 서버 라우트 | `getUserSession(event)`가 봉인된 세션 쿠키를 읽음 |
| `page` | 서버 라우트 | 브라우저가 같은 origin POST에 전체 경로와 함께 보내는 `Referer` 헤더 |
| `screen` | 컴포넌트, 라우트가 확인 | 페이지 파일 경로로 Nuxt가 만드는 `useRoute().name`, 예: `projects-id-settings` |

Nuxt에서 라우트 이름은 공짜로 얻는 그룹 키입니다. 모든 프로젝트의 설정 페이지가 `projects-id-settings`를 공유하므로, `page`에 정확한 주소를 남기면서 화면별로 점수 평균을 낼 수 있습니다.

다이어그램: 하나의 Nuxt 서버 라우트가 JavaScript 유무에 따라 피드백을 처리하는 방식

1. 하이드레이션 후에는 컴포넌트가 기본 제출을 막고 $fetch로 필드를 JSON으로 POST합니다.
2. 서버 라우트가 Origin을 확인하고, 세션에서 사용자를 읽고, 점수를 검증하고, Referer의 페이지를 추가합니다.
3. runtimeConfig의 시크릿 키로 답변을 Ikiform에 전달합니다.
4. JSON 요청은 { sent: true }나 메시지가 담긴 오류를 받고, 폼이 그 자리에서 업데이트됩니다.
5. 하이드레이션 전에는 브라우저가 직접 폼을 POST합니다. 라우트는 같은 확인과 같은 POST를 실행합니다.
6. 그런 다음 결과를 쿼리 문자열에 담아 303으로 원래 페이지로 돌려보내고, 컴포넌트가 이를 표시합니다.

라우트는 요청의 content type으로 둘을 구분합니다. 두 경로 모두 정확히 같은 답변을 저장합니다.

## 프로젝트 설정

Nuxt 4의 구조를 써서 앱 코드는 `app/` 아래, 서버 코드는 루트에 둡니다. `nuxt-auth-utils`와 `zod`를 설치하세요. 앱에서 이미 다른 방식으로 로그인을 처리한다면 그대로 두고 라우트에서 자체 세션으로부터 사용자를 읽으세요.

`프로젝트 트리`:

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

`runtimeConfig`에 빈 기본값으로 시크릿 키를 선언하세요. Nuxt가 런타임에 `NUXT_IKIFORM_SECRET_KEY`에서 값을 채우며, 키가 `public` 아래에 있지 않으므로 서버에만 존재합니다.

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

**API keys**에서 `submissions:write` 스코프로 키를 만드세요. nuxt-auth-utils는 최소 32자인 `NUXT_SESSION_PASSWORD`로 세션 쿠키를 봉인하며, 개발 중에는 첫 실행 시 자동으로 하나 써 줍니다. `session.user.id`에 타입이 붙도록 사용자 형태를 알려 주세요.

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

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

export {};
```

그런 다음 폼의 **Fields** 탭에 정의를 붙여 넣으세요. 처음 두 필드가 폼이고 나머지는 서버 라우트가 추가하며, `email` 필드 덕분에 각 알림의 **Reply**가 사용자에게 갑니다.

폼: In-app feedback

- **이 화면은 어떠신가요?** (`rating`, rating, 필수)
- **무엇이 더 좋아지면 될까요?** (`comment`, textarea)
- **페이지** (`page`, url)
- **화면** (`screen`, text)
- **사용자** (`user_id`, text)
- **이메일** (`email`, email)

미리보기는 Ikiform의 검증기를 실행하므로 점수 `6`을 넣으면 라우트가 돌려받을 `422`가 표시됩니다.

## 만들기

### 서버 라우트

`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");
});
```

- `.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`의 경로만 남기므로 리디렉션이 사이트 밖으로 나갈 수 없습니다.

### 컴포넌트

`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`와 `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 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `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`로 돌아옵니다.

## 작동 확인하기

1. `nuxt dev`를 실행하고 로그인한 뒤 프로젝트 설정 페이지를 열어 코멘트와 함께 점수 2를 보내세요.
2. **Submissions**에서 `user_id`와 `email`이 내 계정과 일치하고, `screen`이 `projects-id-settings`이며, `page`에 실제 경로가 있는지 확인하세요.
3. JavaScript를 끄고 다시 보내세요. 페이지가 새로고침되며 감사 문구가 표시되고 주소에 `?feedback=sent`가 붙습니다.
4. `Origin` 없이 `curl`로 `/api/feedback`에 POST하세요. 라우트가 `403`으로 응답하고 아무것도 Ikiform에 도달하지 않습니다.
5. 세션 쿠키를 지우고 열린 페이지에서 보내세요. 폼에 로그인 메시지가 표시됩니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 프로덕션에서 내 라우트가 `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 서버 라우트에서 로그인한 사용자를 어떻게 가져오나요?

nuxt-auth-utils를 쓴다면 핸들러 안에서 getUserSession(event)나 requireUserSession(event)를 호출하고 session.user를 읽으세요. 쓰지 않는다면 서버 미들웨어에서 event.context.user를 설정하고 그것을 읽으세요. 요청 본문에서 사용자를 가져오지 마세요.

### Nuxt 폼이 JavaScript 없이 작동할 수 있나요?

네. 폼에 서버 라우트를 가리키는 실제 method와 action을 주고, 요청이 JSON이 아니면 라우트가 303으로 원래 페이지로 리디렉션하게 하세요. 하이드레이션 후에는 submit을 가로채 $fetch를 쓰세요.

### Nuxt는 NUXT_ 환경 변수를 어떻게 읽나요?

nuxt.config의 runtimeConfig에 키를 선언하면, Nuxt가 런타임에 NUXT_ 뒤에 키를 대문자 snake case로 붙인 환경 변수로 값을 덮어씁니다. public 밖의 키는 서버에만 남습니다.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [웹사이트나 앱용 피드백 폼](https://ikiform.com/ko/guides/feedback-form.md): 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
- [SvelteKit 피드백 폼](https://ikiform.com/ko/guides/feedback-form/sveltekit.md): SvelteKit 인앱 피드백 폼입니다. 레이아웃의 폼이 하나의 폼 액션으로 POST하고, 액션이 서버에서 locals의 사용자와 요청의 페이지를 추가합니다.
- [Astro 피드백 폼](https://ikiform.com/ko/guides/feedback-form/astro.md): Astro 인앱 피드백 폼입니다. 액션이 미들웨어 locals에서 사용자를, 요청에서 페이지를, 라우트 패턴에서 화면을 가져옵니다.
- [Next.js 피드백 폼](https://ikiform.com/ko/guides/feedback-form/nextjs.md): Next.js 인앱 피드백 폼입니다. 서버 액션이 세션을 읽고, 요청에서 페이지를 가져오고, 사용자 정보를 붙여 평점을 전달합니다.
- [버그 리포트 폼](https://ikiform.com/ko/guides/feedback-form/bug-report.md): 재현 단계와 심각도는 사람에게 묻고, URL, 브라우저, 뷰포트, 최근 오류는 페이지가 알아서 추가하는 버그 리포트 폼입니다.
- [기능 요청 폼](https://ikiform.com/ko/guides/feedback-form/feature-request.md): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
