# Nuxt로 자격 확인 리드 폼 만들기

> Nuxt 리드 폼입니다. 서버 미들웨어가 첫 유입 UTM을 기록하고, Zod로 검증하는 서버 라우트 하나가 fetch와 일반 POST를 모두 처리하며, 좋은 리드는 통화를 예약합니다.

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

첫 페이지 요청에서 Nitro 서버 미들웨어로 UTM 태그와 referrer를 httpOnly 쿠키에 기록하세요. 폼은 Zod로 검증하는 서버 라우트 하나로 POST하고, 그 라우트가 유입 정보와 함께 리드를 폼 백엔드로 전달한 뒤 `$fetch`에는 JSON으로, 일반 폼 POST에는 `303`으로 응답합니다. 클라이언트에서는 `navigateTo(url, { external: true })`가 자격을 갖춘 리드를 스케줄러로 보냅니다.

[메인 가이드의 Nuxt 탭](https://ikiform.com/ko/guides/lead-capture.md#form)은 `$fetch`로 서버 라우트를 거쳐 리드를 보내고 감사 문구를 보여 줍니다. 이 페이지는 영업용 버전을 만듭니다. 서버에서 확인하는 회사 규모, 예산, 일정, 각 리드 뒤의 캠페인, 캘린더로 넘기는 흐름, 그리고 JavaScript가 로드되지 않아도 동작하는 경로입니다.

## Nuxt에서 달라지는 점

Nuxt에는 폼 액션이 없습니다. 폼은 다른 엔드포인트처럼 Nitro 서버 라우트로 POST하므로, 처리해야 할 전송 방식이 두 가지입니다. 하이드레이션된 페이지의 JSON `$fetch`와, 순수 HTML 폼의 URL 인코딩 POST입니다. h3의 `readBody`가 둘 다 파싱하므로 각각에 맞게 응답하기만 하면 라우트 하나로 둘 다 처리할 수 있습니다.

이 편리함에는 대가가 있습니다. URL 인코딩 본문을 받는 라우트는 일반 크로스 사이트 폼 POST도 받습니다. h3 문서도 이를 경고하며, SvelteKit이나 Next.js와 달리 Nuxt는 origin 확인을 대신 해 주지 않습니다. 라우트가 직접 `Origin`을 확인합니다.

다이어그램: 하나의 Nuxt 서버 라우트가 하이드레이션된 폼과 일반 폼 POST에 응답하는 방식

1. JavaScript가 있으면 페이지가 반응형 lead 객체를 $fetch로 JSON POST합니다.
2. 서버 라우트가 다른 origin의 POST를 거부하고, Zod로 검증하고, 유입 정보와 함께 리드를 전달합니다.
3. 다음 URL을 JSON으로 응답하고, navigateTo가 Cal.com이나 감사 페이지로 이동합니다.
4. 검증 실패는 createError로 던져지고, 페이지가 오류의 data에서 필드 메시지를 읽습니다.
5. JavaScript가 없으면 브라우저가 같은 라우트로 폼을 URL 인코딩해 POST합니다.
6. 라우트가 같은 확인을 실행하고 303으로 응답합니다. 스케줄러, 감사 페이지, 또는 실패한 필드 이름과 함께 폼으로 돌아갑니다.

응답은 라우트가 아니라 content type이 정합니다. 검증과 Ikiform POST는 한 번만 작성합니다.

## 프로젝트 설정

Nuxt 4 앱에서 시작해 `zod`를 설치하세요. `runtimeConfig`에 키를 선언하고 `NUXT_IKIFORM_SECRET_KEY`로 설정하세요. 라우트가 `useRuntimeConfig(event)`를 읽을 때 Nuxt가 런타임에 변수를 매핑합니다. 키와 폼은 [Nuxt 문서 페이지](https://ikiform.com/ko/docs/guides/nuxt.md)에서 다룹니다.

`파일`:

```text
app/
  pages/
    contact-sales.vue
    contact-sales/thanks.vue
server/
  middleware/first-touch.ts
  api/lead.post.ts
  utils/lead.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

폼의 **Fields** 탭에 필드를 추가하면 Ikiform이 검증을 반복하고 알 수 없는 키를 거부합니다.

| 필드 ID | Ikiform의 타입 | 설정 주체 |
| --- | --- | --- |
| `name`, `email`, `company` | 텍스트, 이메일, 텍스트 | 방문자 |
| `company_size` | 선택형: `1_10`, `11_50`, `51_200`, `201_1000`, `1000_plus` | 방문자 |
| `budget` | 선택형: `under_10k`, `10k_50k`, `50k_plus`, `unsure` | 방문자 |
| `timeline` | 선택형: `this_quarter`, `next_quarter`, `later`, `researching` | 방문자 |
| `message` | Textarea, 선택 | 방문자 |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | 텍스트, 선택 | 서버, 첫 방문에서 |
| `fit` | 선택형: `qualified`, `nurture` | 서버, 검증 후 |

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

## 첫 유입을 위한 서버 미들웨어

`server/middleware/first-touch.ts`:

```ts
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

export default defineEventHandler((event) => {
  if (event.method !== "GET" || getCookie(event, "lead_source")) return;
  if (!getRequestHeader(event, "accept")?.includes("text/html")) return;

  const url = getRequestURL(event);
  const referer = getRequestHeader(event, "referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href.slice(0, 300) : "";
  const source = new URLSearchParams();
  for (const tag of TAGS) source.set(tag, url.searchParams.get(tag)?.slice(0, 100) ?? "");
  if (!external && !TAGS.some((tag) => source.get(tag))) return;

  source.set("referrer", external);
  source.set("landing_page", url.pathname.slice(0, 200));
  setCookie(event, "lead_source", source.toString(), {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
});
```

- Nitro는 `server/middleware`의 파일을 모든 요청보다 먼저 실행하며, 아무것도 반환하지 않기를 기대합니다. 값을 반환하면 요청이 끝납니다.
- `accept` 확인이 쿠키를 페이지 로드로 제한하므로 API 호출과 에셋은 건너뜁니다.
- `getCookie`, `setCookie` 등 h3 헬퍼는 `server/`에서 자동으로 import됩니다.
- `routeRules`나 `nuxt generate`로 사전 렌더링한 페이지는 파일로 제공되므로 미들웨어가 그 방문을 보지 못합니다. 캠페인 랜딩 페이지는 서버 렌더링으로 두세요.

## 라우트 하나, 응답 둘

`server/utils/lead.ts`:

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

export const LeadSchema = z.object({
  name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
  email: z.email("Enter a work email we can reply to."),
  company: z.string().trim().min(1, "Which company is this for?").max(200),
  company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
  budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
  timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
  message: z.string().trim().max(5000).optional(),
  _gotcha: z.string().optional(),
});

export type Lead = z.infer<typeof LeadSchema>;

export const isQualified = (lead: Lead) =>
  lead.company_size !== "1_10" &&
  (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
  (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");
```

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

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

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export default defineEventHandler(async (event) => {
  const origin = getRequestHeader(event, "origin");
  if (origin && new URL(origin).host !== getRequestHost(event)) {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json");
  const parsed = LeadSchema.safeParse(await readBody(event));

  if (!parsed.success) {
    const fields = z.flattenError(parsed.error).fieldErrors;
    if (!wantsJson) return sendRedirect(event, `/contact-sales?invalid=${Object.keys(fields).join(",")}`, 303);
    throw createError({ status: 400, message: "Check the highlighted fields.", data: { fields } });
  }

  const lead = parsed.data;
  const stored = new URLSearchParams(getCookie(event, "lead_source") ?? "");
  const qualified = isQualified(lead);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      ...lead,
      ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
      fit: qualified ? "qualified" : "nurture",
    }),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    if (!wantsJson) return sendRedirect(event, "/contact-sales?failed=1", 303);
    throw createError({
      status: response.status,
      message: result?.error?.message ?? "We couldn't send that. Try again.",
      data: { fields: result?.error?.fields ?? {} },
    });
  }

  const next =
    qualified && !lead._gotcha
      ? `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`
      : "/contact-sales/thanks";
  return wantsJson ? { next } : sendRedirect(event, next, 303);
});
```

- `server/utils`의 export는 서버 라우트에서 자동으로 import되므로 `LeadSchema`와 `isQualified`에는 import가 필요 없습니다.
- 같은 브라우저 `Origin` 헤더가 두 가지 일을 합니다. 크로스 사이트 POST를 막고, Ikiform으로 전달됩니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더로 무료 `localhost` 테스트를 판별하기 때문입니다.
- `createError`는 필드 메시지를 `data`에 넣고, Nuxt의 오류 응답이 이를 한 번 더 감싸므로 클라이언트는 `error.data.data.fields`를 읽습니다.
- 일반 POST는 세션 없이 상태를 되돌려 보낼 수 없으므로, 실패하면 쿼리 문자열에 실패한 필드 이름을 담아 리디렉션합니다. 방문자가 다시 입력해야 하지만, 대체 경로일 뿐입니다.

## 페이지

`app/pages/contact-sales.vue`:

```vue
<script setup lang="ts">
const route = useRoute();
const lead = reactive({ name: "", email: "", company: "", company_size: "", budget: "", timeline: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[] | undefined>>({});
const failure = ref(route.query.failed ? "We couldn't send that. Email sales@example.com instead." : "");
const sending = ref(false);

for (const key of String(route.query.invalid ?? "").split(",").filter(Boolean)) {
  errors.value[key] = ["Check this field."];
}

const selects = {
  company_size: ["Company size", { "1_10": "1 to 10", "11_50": "11 to 50", "51_200": "51 to 200", "201_1000": "201 to 1,000", "1000_plus": "More than 1,000" }],
  budget: ["Budget", { under_10k: "Under $10k", "10k_50k": "$10k to $50k", "50k_plus": "More than $50k", unsure: "Not sure yet" }],
  timeline: ["When do you want to start?", { this_quarter: "This quarter", next_quarter: "Next quarter", later: "Later this year", researching: "Researching for now" }],
} as const;

async function send() {
  sending.value = true;
  errors.value = {};
  failure.value = "";
  try {
    const { next } = await $fetch<{ next: string }>("/api/lead", { method: "POST", body: lead });
    await navigateTo(next, { external: next.startsWith("https://") });
  } catch (error) {
    const data = (error as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
    errors.value = data?.data?.fields ?? {};
    if (!Object.keys(errors.value).length) failure.value = data?.message ?? "We couldn't send that. Try again.";
    sending.value = false;
  }
}
</script>

<template>
  <form action="/api/lead" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="lead.name" name="name" autocomplete="name" required />
    <p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
    <label for="email">Work email</label>
    <input id="email" v-model="lead.email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
    <label for="company">Company</label>
    <input id="company" v-model="lead.company" name="company" autocomplete="organization" required />
    <p v-if="errors.company" role="alert">{{ errors.company[0] }}</p>
    <template v-for="([label, options], id) in selects" :key="id">
      <label :for="id">{{ label }}</label>
      <select :id="id" v-model="lead[id]" :name="id" required>
        <option value="" disabled>Choose one</option>
        <option v-for="(text, value) in options" :key="value" :value="value">{{ text }}</option>
      </select>
      <p v-if="errors[id]" role="alert">{{ errors[id]?.[0] }}</p>
    </template>
    <label for="message">Anything we should know?</label>
    <textarea id="message" v-model="lead.message" name="message" rows="4" />
    <input v-model="lead._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="failure" role="alert">{{ failure }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Talk to sales" }}</button>
  </form>
</template>
```

Nuxt가 하이드레이션되면 `@submit.prevent`가 넘겨받고, 반응형 `lead` 객체가 오류가 나도 모든 값을 유지합니다. `navigateTo`는 `external`이 설정되지 않으면 절대 URL을 거부하므로, 플래그는 URL에 따라 정합니다. Cal.com에는 external, 감사 페이지에는 클라이언트 내비게이션입니다. 하이드레이션 전에는 `action` 속성이 같은 라우트로 POST하고, 페이지가 `?invalid=`를 읽어 필드를 표시합니다.

### 허니팟과 사람 확인

허니팟은 스키마에 있고 전달되므로 Ikiform이 값이 채워진 것을 스팸으로 저장하고, 라우트는 이를 감사 페이지로 보냅니다. 시크릿 키는 Ikiform의 사람 확인을 건너뜁니다. 필요하다면 [폼에 Turnstile 적용하기](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)처럼 라우트에서 Turnstile 토큰을 검증하세요.

## 배포 참고 사항

| 호스트 | 알아 둘 점 |
| --- | --- |
| Vercel | Nitro가 Vercel을 감지해 함수로 빌드합니다. 환경 변수에 `NUXT_IKIFORM_SECRET_KEY`를 설정하세요. |
| Netlify | Nitro는 Netlify도 감지합니다. 사이트 설정에서 변수를 설정하세요. |
| Cloudflare | `cloudflare_module` 프리셋으로 빌드하고 `wrangler secret put`으로 변수를 저장하세요. 이벤트와 함께 `useRuntimeConfig(event)`를 읽으면 런타임에 값을 가져옵니다. |

## 작동 확인하기

`nuxt dev`에서 보낸 POST에는 `localhost` origin이 담깁니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받습니다. 실제 운영하려면 월 $6부터 500건을 제공하는 Pro를 고르거나, 한 번 결제로 10,000건 $49 또는 50,000건 $149를 구매하세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

1. `nuxt dev`를 실행하고 `/?utm_source=test&utm_campaign=launch`를 연 뒤 개발자 도구에서 `lead_source` 쿠키를 확인하세요.
2. `/contact-sales`에서 **Timeline**(일정)을 비운 채 제출하세요. 오류가 표시되고 다른 답은 유지됩니다.
3. JavaScript를 끄고 같은 방식으로 제출하세요. 필드가 표시된 `/contact-sales?invalid=timeline`으로 돌아옵니다.
4. 자격을 갖춘 리드로 제출하세요. 11~50명, $50k 초과, 이번 분기입니다. 이름과 이메일이 채워진 Cal.com으로 이동합니다.
5. 대시보드에서 **Submissions**를 열고 `utm_campaign`, `referrer`, `fit`을 확인하세요.
6. 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에 `email` 필드가 있으면 **Reply**는 제출자에게 갑니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 리드가 자격을 갖추면 `navigateTo`가 예외를 던짐 | `external: true`가 없습니다. `navigateTo`는 이것 없이 다른 사이트의 절대 URL을 거부합니다. |
| 프로덕션에서 키가 비어 있음 | 변수 이름은 `NUXT_IKIFORM_SECRET_KEY`여야 하고, 런타임에 읽히도록 라우트가 `useRuntimeConfig(event)`를 호출해야 합니다. |
| 프록시 뒤에서 `403 Cross-site post` | 프록시가 라우트가 보는 `Host`를 바꿉니다. 원래 호스트를 전달하거나, 대신 origin을 공개 도메인과 비교하세요. |
| API 호출이나 이미지에 쿠키가 설정됨 | `accept` 확인이 제거되었습니다. 미들웨어를 페이지 로드로 제한하세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |

## 질문

### Nuxt에서 외부 URL로 어떻게 리디렉션하나요?

클라이언트에서는 navigateTo(url, { external: true })를 호출하세요. 플래그가 없으면 다른 사이트에 대해 예외를 던집니다. 서버 라우트에서는 sendRedirect(event, url, 303)으로 일반 폼 POST에 리디렉션으로 응답합니다.

### Nuxt 서버 라우트 하나로 $fetch와 일반 폼 POST를 모두 처리할 수 있나요?

네. readBody가 JSON과 URL 인코딩 본문을 파싱하므로 content type을 확인해 $fetch에는 JSON을, 기본 POST에는 303 리디렉션을 응답하세요. 라우트가 이제 크로스 사이트 폼 POST도 받으므로 Origin 헤더도 확인하세요.

### Nuxt에서 UTM 추적은 어디에 두어야 하나요?

server/middleware 아래의 파일에 두세요. 모든 서버 요청보다 먼저 실행되어 쿼리 문자열과 referrer를 읽을 수 있고, 리드 라우트가 나중에 읽는 httpOnly 쿠키를 설정합니다. 파일로 제공되는 사전 렌더링 페이지에서는 실행되지 않습니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [리드 수집 폼](https://ikiform.com/ko/guides/lead-capture.md): reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.
- [SvelteKit 리드 폼](https://ikiform.com/ko/guides/lead-capture/sveltekit.md): SvelteKit 리드 폼입니다. handle 훅이 첫 유입 UTM을 기록하고, 폼 액션이 Valibot으로 검증하며, redirect()가 자격을 갖춘 리드를 캘린더로 보냅니다.
- [Next.js 리드 폼](https://ikiform.com/ko/guides/lead-capture/nextjs.md): 서버 액션에서 Zod로 검증하고, 첫 유입 UTM을 쿠키에 보관하고, 자격을 갖춘 리드를 캘린더로 보내는 Next.js App Router 리드 폼입니다.
- [데모 요청 폼](https://ikiform.com/ko/guides/lead-capture/demo-request.md): 리드를 저장한 뒤 이름과 이메일이 미리 채워진 Cal.com이나 Calendly로 방문자를 보내는 데모 예약 폼입니다.
- [견적 요청 폼](https://ikiform.com/ko/guides/lead-capture/quote-request.md): 자료를 업로드 대신 공유 링크로 받고, 모든 요청에 기한을 붙이고, 링크가 많은 리드가 스팸으로 분류되지 않게 하는 견적 요청 폼입니다.
- [상담 신청 폼](https://ikiform.com/ko/guides/lead-capture/consultation-request.md): 회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
