리드 수집 폼6분 분량
Nuxt로 자격 확인 리드 폼 만들기
Nuxt 리드 폼입니다. 서버 미들웨어가 첫 유입 UTM을 기록하고, Zod로 검증하는 서버 라우트 하나가 fetch와 일반 POST를 모두 처리하며, 좋은 리드는 통화를 예약합니다.
첫 페이지 요청에서 Nitro 서버 미들웨어로 UTM 태그와 referrer를 httpOnly 쿠키에 기록하세요. 폼은 Zod로 검증하는 서버 라우트 하나로 POST하고, 그 라우트가 유입 정보와 함께 리드를 폼 백엔드로 전달한 뒤 $fetch에는 JSON으로, 일반 폼 POST에는 303으로 응답합니다. 클라이언트에서는 navigateTo(url, { external: true })가 자격을 갖춘 리드를 스케줄러로 보냅니다.
메인 가이드의 Nuxt 탭은 $fetch로 서버 라우트를 거쳐 리드를 보내고 감사 문구를 보여 줍니다. 이 페이지는 영업용 버전을 만듭니다. 서버에서 확인하는 회사 규모, 예산, 일정, 각 리드 뒤의 캠페인, 캘린더로 넘기는 흐름, 그리고 JavaScript가 로드되지 않아도 동작하는 경로입니다.
Nuxt에서 달라지는 점
Nuxt에는 폼 액션이 없습니다. 폼은 다른 엔드포인트처럼 Nitro 서버 라우트로 POST하므로, 처리해야 할 전송 방식이 두 가지입니다. 하이드레이션된 페이지의 JSON $fetch와, 순수 HTML 폼의 URL 인코딩 POST입니다. h3의 readBody가 둘 다 파싱하므로 각각에 맞게 응답하기만 하면 라우트 하나로 둘 다 처리할 수 있습니다.
이 편리함에는 대가가 있습니다. URL 인코딩 본문을 받는 라우트는 일반 크로스 사이트 폼 POST도 받습니다. h3 문서도 이를 경고하며, SvelteKit이나 Next.js와 달리 Nuxt는 origin 확인을 대신 해 주지 않습니다. 라우트가 직접 Origin을 확인합니다.
- JavaScript가 있으면 페이지가 반응형 lead 객체를 $fetch로 JSON POST합니다.
- 서버 라우트가 다른 origin의 POST를 거부하고, Zod로 검증하고, 유입 정보와 함께 리드를 전달합니다.
- 다음 URL을 JSON으로 응답하고, navigateTo가 Cal.com이나 감사 페이지로 이동합니다.
- 검증 실패는 createError로 던져지고, 페이지가 오류의 data에서 필드 메시지를 읽습니다.
- JavaScript가 없으면 브라우저가 같은 라우트로 폼을 URL 인코딩해 POST합니다.
- 라우트가 같은 확인을 실행하고 303으로 응답합니다. 스케줄러, 감사 페이지, 또는 실패한 필드 이름과 함께 폼으로 돌아갑니다.
프로젝트 설정
Nuxt 4 앱에서 시작해 zod를 설치하세요. runtimeConfig에 키를 선언하고 NUXT_IKIFORM_SECRET_KEY로 설정하세요. 라우트가 useRuntimeConfig(event)를 읽을 때 Nuxt가 런타임에 변수를 매핑합니다. 키와 폼은 Nuxt 문서 페이지에서 다룹니다.
app/
pages/
contact-sales.vue
contact-sales/thanks.vue
server/
middleware/first-touch.ts
api/lead.post.ts
utils/lead.ts
nuxt.config.ts
.envexport 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 | 서버, 검증 후 |
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.첫 유입을 위한 서버 미들웨어
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로 사전 렌더링한 페이지는 파일로 제공되므로 미들웨어가 그 방문을 보지 못합니다. 캠페인 랜딩 페이지는 서버 렌더링으로 두세요.
라우트 하나, 응답 둘
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");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는 세션 없이 상태를 되돌려 보낼 수 없으므로, 실패하면 쿼리 문자열에 실패한 필드 이름을 담아 리디렉션합니다. 방문자가 다시 입력해야 하지만, 대체 경로일 뿐입니다.
페이지
<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 적용하기처럼 라우트에서 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를 구매하세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
nuxt dev를 실행하고/?utm_source=test&utm_campaign=launch를 연 뒤 개발자 도구에서lead_source쿠키를 확인하세요./contact-sales에서 Timeline(일정)을 비운 채 제출하세요. 오류가 표시되고 다른 답은 유지됩니다.- JavaScript를 끄고 같은 방식으로 제출하세요. 필드가 표시된
/contact-sales?invalid=timeline으로 돌아옵니다. - 자격을 갖춘 리드로 제출하세요. 11~50명, $50k 초과, 이번 분기입니다. 이름과 이메일이 채워진 Cal.com으로 이동합니다.
- 대시보드에서 Submissions를 열고
utm_campaign,referrer,fit을 확인하세요. - 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에
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와 맞추세요. |
질문
클라이언트에서는 navigateTo(url, { external: true })를 호출하세요. 플래그가 없으면 다른 사이트에 대해 예외를 던집니다. 서버 라우트에서는 sendRedirect(event, url, 303)으로 일반 폼 POST에 리디렉션으로 응답합니다.
네. readBody가 JSON과 URL 인코딩 본문을 파싱하므로 content type을 확인해 $fetch에는 JSON을, 기본 POST에는 303 리디렉션을 응답하세요. 라우트가 이제 크로스 사이트 폼 POST도 받으므로 Origin 헤더도 확인하세요.
server/middleware 아래의 파일에 두세요. 모든 서버 요청보다 먼저 실행되어 쿼리 문자열과 referrer를 읽을 수 있고, 리드 라우트가 나중에 읽는 httpOnly 쿠키를 설정합니다. 파일로 제공되는 사전 렌더링 페이지에서는 실행되지 않습니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.