# Next.js로 자격 확인 리드 폼 만들기

> 서버 액션에서 Zod로 검증하고, 첫 유입 UTM을 쿠키에 보관하고, 자격을 갖춘 리드를 캘린더로 보내는 Next.js App Router 리드 폼입니다.

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

첫 방문 때 `proxy.ts`에서 캠페인 태그와 referrer를 잡아 쿠키에 보관하세요. 서버 액션에서 Zod로 리드를 검증하고, 회사 규모, 예산, 일정으로 점수를 매긴 뒤 유입 정보와 함께 폼 백엔드로 전달하세요. 그런 다음 `redirect()`를 호출해 자격을 갖춘 리드는 스케줄러로, 나머지는 감사 페이지로 보내세요.

[메인 가이드의 Next.js 탭](https://ikiform.com/ko/guides/lead-capture.md#form)은 리드를 전달하고 감사 문구를 보여 줍니다. 이 페이지는 영업용 버전을 만듭니다. 서버에서 확인하는 자격 확인 질문 세 개, 방문자가 전환 전에 여러 페이지를 돌아다녀도 남는 캠페인 데이터, 그리고 통화할 가치가 있는 리드를 캘린더로 바로 넘기는 흐름입니다.

## Next.js에서 달라지는 점

대부분의 일은 Next.js 기능 두 가지가 합니다. Next.js 16 이전에 `middleware.ts`라고 불리던 `proxy.ts`는 정적 페이지를 포함해 모든 페이지 요청보다 먼저 실행됩니다. 그래서 첫 방문 때 `?utm_source=`와 `Referer` 헤더를 읽기에 알맞은 곳입니다. 방문자가 **Talk to sales**에 도달할 즈음에는 둘 다 URL에서 사라졌기 때문입니다. 그런 다음 서버 액션이 쿠키를 읽으므로 문의 페이지 자체는 정적으로 둘 수 있습니다.

두 번째는 `redirect()`입니다. 서버 액션에서는 절대 URL을 받으므로 자격을 갖춘 리드가 클라이언트 코드 없이 Cal.com에 도착합니다. JavaScript가 로드되어 있으면 Next.js가 클라이언트에서 이동하고, 하이드레이션 전에는 폼 POST에 `303`으로 응답합니다.

다이어그램: Next.js 리드가 첫 광고 클릭부터 예약된 통화까지 가는 과정

1. 방문자가 UTM 태그가 붙은 캠페인 링크나 다른 사이트의 링크로 들어옵니다.
2. proxy.ts가 태그, referrer, 랜딩 경로를 httpOnly 쿠키에 한 번 저장합니다.
3. 요금 페이지와 문의 페이지는 정적으로 유지되며 캐시에서 제공됩니다.
4. 제출 시 서버 액션이 Zod로 필드를 검증하고 쿠키를 읽습니다.
5. 리드, 유입 정보, 계산된 적합도를 시크릿 키로 Ikiform에 POST합니다.
6. 자격을 갖춘 리드는 이름과 이메일이 채워진 스케줄러로 리디렉션되고, 나머지는 감사 페이지로 갑니다.
7. 검증 오류는 방문자가 입력한 모든 값과 함께 액션 상태로 돌아옵니다.

쿠키가 첫 유입 정보를 페이지 간에 실어 나르므로 문의 페이지는 URL을 읽을 필요가 없습니다.

## 프로젝트 설정

Next.js 16의 아무 App Router 프로젝트에서 시작하세요. `zod`를 설치하고, 대시보드에서 폼을 만들고, **API keys**에서 `submissions:write` 스코프로 시크릿 키를 만드세요. 키와 폼 설정은 [Next.js 문서 페이지](https://ikiform.com/ko/docs/guides/nextjs.md)에서 자세히 다룹니다.

`파일`:

```text
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
```

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

그런 다음 폼의 **Fields** 탭에 필드를 추가해 Ikiform도 강제하게 하세요. 정의가 없으면 Ikiform은 보내는 것을 무엇이든 저장하고, 정의가 있으면 알 수 없는 필드는 `422`가 됩니다. 페이로드에 유입 정보가 들어가면 바로 이게 필요합니다.

| 필드 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`는 서버 전용 환경 변수에 두세요.

## proxy.ts에서 첫 유입 정보 기록하기

`proxy.ts`:

```ts
import { type NextRequest, NextResponse } from "next/server";

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

export function proxy(request: NextRequest) {
  const response = NextResponse.next();
  if (request.cookies.has("lead_source")) return response;

  const url = request.nextUrl;
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href : "";
  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 response;

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

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
```

- 쿠키는 한 번 설정되고 덮어쓰지 않으므로, 마지막 내부 클릭이 아니라 첫 캠페인을 기록합니다. 마지막 유입을 원한다면 `has` 확인을 지우세요.
- 내 호스트에서 온 referrer는 무시합니다. 그러지 않으면 첫 페이지 이후의 모든 페이지 뷰가 나 자신에게서 온 유입으로 집계됩니다.
- matcher는 정적 파일과 라우터 프리페치를 건너뜁니다. 프리페치는 내 페이지를 referrer로 담고 있어서, 그러지 않으면 화면의 모든 링크마다 proxy가 실행됩니다.
- `URLSearchParams`는 간결한 쿠키 값과 안전한 파서를 제공합니다. 액션은 아는 키 다섯 개만 읽으므로, 쿠키를 편집한 방문자가 필드를 추가할 수 없습니다.

## 검증, 점수 매기기, 넘겨주기

스키마는 별도 모듈에 두어 액션과 나중에 생길 API 라우트가 공유하게 합니다. 적합 규칙은 일부러 단순하게 했습니다. 11명 이상, 예산 $10k 이상, 두 분기 안의 시작입니다.

`lib/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(),
});

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

export function isQualified(lead: Lead) {
  const bigEnough = lead.company_size !== "1_10";
  const funded = lead.budget === "10k_50k" || lead.budget === "50k_plus";
  const soon = lead.timeline === "this_quarter" || lead.timeline === "next_quarter";
  return bigEnough && funded && soon;
}
```

`app/contact-sales/actions.ts`:

```ts
"use server";

import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { isQualified, LeadSchema } from "@/lib/lead";

export interface LeadState {
  errors: Record<string, string>;
  values: Record<string, string>;
}

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

export async function submitLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
  const values = Object.fromEntries([...formData].map(([key, value]) => [key, String(value)]));
  const parsed = LeadSchema.safeParse(values);
  if (!parsed.success) {
    const { fieldErrors } = z.flattenError(parsed.error);
    const errors = Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""]));
    return { errors, values };
  }

  const stored = new URLSearchParams((await cookies()).get("lead_source")?.value ?? "");
  const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
  const origin = (await headers()).get("origin");
  const qualified = isQualified(parsed.data);

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      ...parsed.data,
      ...source,
      fit: qualified ? "qualified" : "nurture",
      _gotcha: values._gotcha ?? "",
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
    if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
    return { errors, values };
  }

  if (!qualified || values._gotcha) redirect("/contact-sales/thanks");
  const { name, email } = parsed.data;
  redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name, email })}`);
}
```

- Zod는 서버 밖으로 무엇이든 나가기 전에 실행되고, `z.flattenError`가 이슈를 필드당 메시지 하나로 바꿉니다.
- 브라우저는 모든 서버 액션 POST에 `Origin` 헤더를 보내고, 액션이 이를 전달합니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더로 무료 `localhost` 테스트를 판별합니다.
- `fit`은 서버에서 계산합니다. 방문자가 폼을 편집해 스스로를 자격 있음으로 표시할 수 없습니다.
- 두 `redirect()` 호출 모두 `try` 블록 밖에 있습니다. `redirect()`는 예외를 던져서 동작하므로 `catch`가 삼켜 버립니다.
- 허니팟이 채워진 경우에도 POST는 되므로 Ikiform이 스팸으로 기록하지만, 캘린더에는 절대 닿지 않습니다.

## JavaScript가 있을 때와 없을 때의 폼

`app/contact-sales/lead-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type LeadState, submitLead } from "./actions";

const initial: LeadState = { errors: {}, values: {} };

function FieldError({ message }: { message?: string }) {
  return message ? <p role="alert">{message}</p> : null;
}

export function LeadForm() {
  const [state, action, pending] = useActionState(submitLead, initial);
  const { errors, values } = state;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      <FieldError message={errors.name} />

      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      <FieldError message={errors.email} />

      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
      <FieldError message={errors.company} />

      <label htmlFor="company_size">Company size</label>
      <select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""} key={values.company_size}>
        <option value="" disabled>Choose one</option>
        <option value="1_10">1 to 10</option>
        <option value="11_50">11 to 50</option>
        <option value="51_200">51 to 200</option>
        <option value="201_1000">201 to 1,000</option>
        <option value="1000_plus">More than 1,000</option>
      </select>
      <FieldError message={errors.company_size} />

      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue={values.budget ?? ""} key={values.budget}>
        <option value="" disabled>Choose one</option>
        <option value="under_10k">Under $10k</option>
        <option value="10k_50k">$10k to $50k</option>
        <option value="50k_plus">More than $50k</option>
        <option value="unsure">Not sure yet</option>
      </select>
      <FieldError message={errors.budget} />

      <label htmlFor="timeline">When do you want to start?</label>
      <select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""} key={values.timeline}>
        <option value="" disabled>Choose one</option>
        <option value="this_quarter">This quarter</option>
        <option value="next_quarter">Next quarter</option>
        <option value="later">Later this year</option>
        <option value="researching">Researching for now</option>
      </select>
      <FieldError message={errors.timeline} />

      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />

      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <FieldError message={errors.form} />
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

`app/contact-sales/page.tsx`:

```tsx
import { LeadForm } from "./lead-form";

export default function ContactSales() {
  return (
    <main>
      <h1>Talk to sales</h1>
      <p>Tell us a little about your team. If we look like a fit, you pick a call time on the next screen.</p>
      <LeadForm />
    </main>
  );
}
```

`useActionState`가 폼에 pending 플래그와 반환된 오류를 줍니다. 액션이 서버 함수이므로 폼은 하이드레이션 전에도 POST됩니다. Next.js가 서버에서 결과를 렌더링하고, 방문자는 전체 페이지 로드만 다를 뿐 같은 오류를 봅니다.

React는 액션 실행 후 폼을 초기화하므로, 모든 오류 경로가 `values`를 반환하고 각 입력이 이를 `defaultValue`로 다시 읽습니다. 각 `select`의 `key`는 반환된 선택으로 다시 마운트되게 합니다. `select`는 바뀐 `defaultValue`를 스스로 반영하지 않습니다.

### 허니팟과 사람 확인

hidden 허니팟은 그대로 전달됩니다. 시크릿 키 POST에서는 Ikiform 자체의 사람 확인이 실행되지 않으므로, 이 폼에 봇 트래픽이 몰린다면 [폼에 Turnstile 적용하기](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)처럼 POST 전에 액션에서 Turnstile 토큰을 검증하세요.

## 렌더링과 배포

문의 페이지에는 요청 데이터를 읽는 것이 없으므로 `next build`가 사전 렌더링합니다. proxy와 액션은 요청마다 실행됩니다. 호스트가 이를 어떻게 실행하는지 확인하세요.

| 호스트 | 알아 둘 점 |
| --- | --- |
| Vercel | proxy와 서버 액션은 설정 없이 동작합니다. 프로젝트의 환경 변수에서 Production과 Preview에 `IKIFORM_SECRET_KEY`를 추가하세요. |
| Netlify | Next.js는 설정 없이 Netlify 어댑터로 실행됩니다. 사이트의 환경 변수에 functions 스코프로 키를 설정하세요. |
| Cloudflare | OpenNext Cloudflare 어댑터로 배포하고 `wrangler secret put`으로 키를 저장하세요. 쿠키에 의존하기 전에 문서에서 현재 proxy 지원 여부를 확인하세요. |

## 작동 확인하기

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

1. `next dev`를 실행하고 `/pricing?utm_source=test&utm_campaign=launch`를 여세요. 브라우저 개발자 도구에서 `lead_source` 쿠키를 확인하세요.
2. `/contact-sales`로 이동해 **Company size**(회사 규모)를 비운 채 제출하세요. 필드 아래에 오류가 표시되고 다른 값은 유지됩니다.
3. 자격을 갖춘 리드로 제출하세요. 51~200명, $10k~$50k, 이번 분기입니다. 이름과 이메일이 채워진 Cal.com으로 이동합니다.
4. 1~10명 팀으로 제출하세요. `/contact-sales/thanks`로 이동합니다.
5. 대시보드에서 **Submissions**를 여세요. 두 리드 모두 `utm_source`, `landing_page`, `fit`과 함께 있습니다.
6. 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에 `email` 필드가 있으면 **Reply**는 제출자에게 갑니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| UTM 필드가 비어서 도착함 | 태그를 추가하기 전에 쿠키가 설정되었거나, proxy 파일이 프로젝트 루트(또는 `src/`를 쓴다면 그 안)에 없습니다. 쿠키를 지우고 태그와 함께 새로고침하세요. |
| 리디렉션이 일어나지 않고 폼에도 아무것도 표시되지 않음 | `redirect()`가 `try` 안에서 호출되었습니다. 예제처럼 블록 뒤로 옮기세요. |
| `fit`이나 `referrer`에 대해 `Unknown field`와 함께 `422` | 폼의 필드 정의에 없습니다. 표의 모든 ID를 선택 필드로 추가하세요. |
| `402 test_limit_reached` | 워크스페이스에 플랜이 없고 이번 달 무료 `localhost` 테스트 50건을 다 썼습니다. 플랜을 고르거나 대시보드의 **Send test**를 사용하세요. |
| 오류 후 select가 **Choose one**(선택하세요)으로 초기화됨 | `key` prop이 없어서 React가 초기화된 `select`를 유지합니다. 각 select의 key를 반환된 값으로 지정하세요. |

## 질문

### Next.js 서버 액션에서 외부 URL로 리디렉션할 수 있나요?

네. next/navigation의 redirect()는 절대 URL을 받습니다. 서버 액션에서는 JavaScript가 로드되어 있으면 클라이언트에서 이동하고, 하이드레이션 전에 POST된 폼에는 303으로 응답합니다. 예외를 던져서 동작하므로 try 블록 밖에서 호출하세요.

### UTM 파라미터를 문의 페이지가 아니라 proxy.ts에서 잡는 이유는 무엇인가요?

방문자는 처음 들어온 페이지에서 전환하는 경우가 드뭅니다. proxy는 첫 요청에서 실행되어 태그와 referrer를 쿠키에 저장하고, 서버 액션이 나중에 이를 읽습니다. 문의 페이지는 URL을 읽지 않으므로 정적으로 유지됩니다.

### Next.js에서 리드 자격을 확인하려면 데이터베이스가 필요한가요?

아니요. 서버 액션이 리드에 점수를 매기고, 결과를 fit 필드에 담아 Ikiform으로 전달하고, 리디렉션합니다. Ikiform이 리드를 저장하고 팀에 이메일을 보내며 CRM으로 웹훅을 보낼 수 있습니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [리드 수집 폼](https://ikiform.com/ko/guides/lead-capture.md): reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.
- [데모 요청 폼](https://ikiform.com/ko/guides/lead-capture/demo-request.md): 리드를 저장한 뒤 이름과 이메일이 미리 채워진 Cal.com이나 Calendly로 방문자를 보내는 데모 예약 폼입니다.
- [Astro 리드 폼](https://ikiform.com/ko/guides/lead-capture/astro.md): Actions로 만든 Astro 리드 폼입니다. Zod 검증, 정적 사이트에서의 첫 유입 UTM, 요청 시 렌더링되는 문의 페이지, 스케줄러로의 리디렉션을 다룹니다.
- [React Router 리드 폼](https://ikiform.com/ko/guides/lead-capture/react-router.md): React Router 프레임워크 모드 리드 폼입니다. 서명된 첫 유입 쿠키를 쓰는 루트 미들웨어, 검증하는 라우트 액션, 스케줄러로의 리디렉션으로 구성합니다.
- [견적 요청 폼](https://ikiform.com/ko/guides/lead-capture/quote-request.md): 자료를 업로드 대신 공유 링크로 받고, 모든 요청에 기한을 붙이고, 링크가 많은 리드가 스팸으로 분류되지 않게 하는 견적 요청 폼입니다.
- [상담 신청 폼](https://ikiform.com/ko/guides/lead-capture/consultation-request.md): 회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
