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

> TanStack Start 리드 폼입니다. 첫 유입 UTM을 위한 전역 요청 미들웨어, Zod를 쓰는 서버 함수, 스케줄러로 던지는 리디렉션으로 구성합니다.

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

`src/start.ts`의 전역 요청 미들웨어로 첫 페이지 요청에서 UTM 태그와 referrer를 기록해 httpOnly 쿠키에 보관하세요. `createServerFn` 핸들러에서 Zod로 리드를 검증하고, 유입 정보와 함께 폼 백엔드로 전달한 뒤, `redirect({ href })`를 throw해 자격을 갖춘 리드를 스케줄러로 보내세요. 서버 함수를 `useServerFn`으로 호출하면 리디렉션을 대신 따라갑니다.

[메인 가이드의 TanStack Start 탭](https://ikiform.com/ko/guides/lead-capture.md#form)은 서버 함수로 리드를 보내고 감사 문구를 보여 줍니다. 이 페이지는 영업용 버전을 만듭니다. 서버에서 검증하는 자격 확인 질문 세 개, 방문자를 데려온 캠페인, 그리고 리드가 적합하면 캘린더로 바로 가는 리디렉션입니다.

## TanStack Start에서 달라지는 점

Start에는 파일 기반 미들웨어 규칙이 없습니다. 모든 요청에서 실행되어야 하는 것은 `src/start.ts`에 전역 요청 미들웨어로 넣으며, SSR, 서버 라우트, 서버 함수보다 모두 먼저 실행됩니다. 첫 유입은 거기서 기록합니다.

주의할 점이 있습니다. Start는 `src/start.ts`가 없을 때만 서버 함수용 CSRF 보호를 자동으로 설치합니다. 유입 추적을 위해 이 파일을 만드는 순간 그 보호는 내 책임이 되므로 `createCsrfMiddleware()`를 직접 추가해야 합니다. 잊으면 리드 엔드포인트가 크로스 사이트 POST를 받아들입니다.

다이어그램: 리드가 TanStack Start의 미들웨어와 서버 함수를 거치는 과정

1. 모든 요청은 먼저 CSRF 미들웨어를 통과합니다. 서버 함수 호출만 확인합니다.
2. 아직 쿠키가 없는 GET이면 firstTouch가 UTM 태그와 외부 referrer를 읽어 저장합니다.
3. 페이지는 평소처럼 렌더링됩니다.
4. 제출 시 useServerFn이 FormData를 서버 함수로 POST합니다.
5. 검증기가 일반 객체로 바꾸고, 핸들러가 Zod를 실행합니다.
6. 유효한 리드는 유입 정보, 적합도, 방문자의 Origin과 함께 Ikiform으로 갑니다.
7. 핸들러가 Cal.com이나 감사 라우트로의 리디렉션을 throw하고, useServerFn이 따라갑니다. 오류는 데이터로 돌아옵니다.

src/start.ts를 작성하면 Start의 기본 요청 미들웨어가 대체되므로 CSRF는 직접 다시 넣어야 합니다.

## 프로젝트 설정

React 기반 TanStack Start 프로젝트에서 시작하고 `zod`를 설치하세요. 시크릿 키는 `.env`에 `IKIFORM_SECRET_KEY`로 넣으세요. 서버 함수는 `process.env`에서 읽으며, 클라이언트 번들에는 절대 들어가지 않습니다. 문서에는 Start 전용 페이지가 없으며, 키와 엔드포인트는 [서버에서 POST하기](https://ikiform.com/ko/docs/guides/server.md)에서 다룹니다.

`파일`:

```text
src/
  start.ts
  lib/lead.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
vite.config.ts
.env
```

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

## 첫 유입을 위한 전역 미들웨어

`src/start.ts`:

```ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";

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

const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });

const firstTouch = createMiddleware().server(({ request, next }) => {
  if (request.method !== "GET" || getCookie("lead_source")) return next();

  const url = new URL(request.url);
  const referer = request.headers.get("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))) {
    source.set("referrer", external);
    source.set("landing_page", url.pathname.slice(0, 200));
    setCookie("lead_source", source.toString(), {
      httpOnly: true,
      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24 * 30,
    });
  }
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [csrf, firstTouch],
}));
```

- 필터는 Start 문서가 전역 사용 시 권장하는 대로 CSRF 확인을 서버 함수에만 적용합니다. `Sec-Fetch-Site`, `Origin`, `Referer`를 요청의 origin과 비교합니다.
- `firstTouch`는 GET이 아니면 건너뛰므로 서버 함수 POST는 쿠키를 건드리지 않습니다.
- `setCookie`와 `getCookie`는 `@tanstack/react-start/server`에서 오며 서버에서만 실행됩니다.
- 요청 중에 설정한 쿠키는 다음 요청까지 `getCookie`로 읽을 수 없습니다. 여기서는 괜찮습니다. 리드는 나중 요청에서 제출됩니다.

## 서버 함수

`src/lib/lead.ts`:

```ts
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, getRequestHeader } from "@tanstack/react-start/server";
import { z } from "zod";

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

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
const FIT = { size: ["1_10"], budget: ["10k_50k", "50k_plus"], timeline: ["this_quarter", "next_quarter"] };

export type LeadResult = { errors: Record<string, string> };

export const submitLead = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return Object.fromEntries([...data].map(([key, value]) => [key, String(value)]));
  })
  .handler(async ({ data }): Promise<LeadResult> => {
    const parsed = LeadSchema.safeParse(data);
    if (!parsed.success) {
      const { fieldErrors } = z.flattenError(parsed.error);
      return { errors: Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""])) };
    }
    const lead = parsed.data;
    const stored = new URLSearchParams(getCookie("lead_source") ?? "");
    const qualified =
      !FIT.size.includes(lead.company_size) && FIT.budget.includes(lead.budget) && FIT.timeline.includes(lead.timeline);
    const origin = getRequestHeader("origin");

    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({
        ...lead,
        ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
        fit: qualified ? "qualified" : "nurture",
      }),
    });
    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] ?? ""]));
      return { errors: Object.keys(errors).length ? errors : { form: result?.error?.message ?? "Try again." } };
    }

    if (!qualified || lead._gotcha) throw redirect({ to: "/contact-sales/thanks" });
    throw redirect({ href: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` });
  });
```

- `.validator()`는 `FormData`의 형태만 바꿉니다. Zod는 핸들러에서 `safeParse`로 실행되므로, 필드 오류가 컴포넌트가 필드별로 읽을 수 없는 예외가 아니라 데이터로 반환됩니다.
- `fit`은 서버에서 정합니다. 폼을 편집해도 리드의 등급을 올릴 수 없습니다.
- `getRequestHeader("origin")`을 전달합니다. 서버 측 fetch는 `Origin`을 보내지 않는데, Ikiform은 이 헤더로 무료 `localhost` 테스트를 판별하기 때문입니다.
- `redirect({ href })`는 외부용이고 `redirect({ to })`는 앱 안에 머뭅니다. 둘 다 throw합니다.

## 라우트

`src/routes/contact-sales.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { submitLead } from "../lib/lead";

export const Route = createFileRoute("/contact-sales")({ component: ContactSales });

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;

function ContactSales() {
  const send = useServerFn(submitLead);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      const result = await send({ data: new FormData(event.currentTarget) });
      setErrors(result?.errors ?? {});
    } catch {
      setErrors({ form: "We couldn't send that. Email sales@example.com instead." });
    } finally {
      setPending(false);
    }
  }

  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <form method="post" action={submitLead.url} encType="multipart/form-data" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      {error("company")}
      {Object.entries(SELECTS).map(([id, [label, options]]) => (
        <div key={id}>
          <label htmlFor={id}>{label}</label>
          <select id={id} name={id} required defaultValue="">
            <option value="" disabled>Choose one</option>
            {options.map(([value, text]) => (
              <option key={value} value={value}>{text}</option>
            ))}
          </select>
          {error(id)}
        </div>
      ))}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

submit 핸들러는 기본 POST를 막고 `useServerFn`을 거쳐 서버 함수를 호출합니다. 다른 사이트로의 리디렉션을 포함해 throw된 리디렉션을 따라가는 것이 바로 이것입니다. 폼이 초기화되지 않으므로 방문자가 필드를 고치는 동안 모든 값이 그대로 남습니다.

`action={submitLead.url}` 속성은 JavaScript 없는 경로입니다. Start는 HTML 폼용으로 `.url`을 문서화하지만, 그러면 브라우저가 이 페이지 대신 서버 함수의 원시 응답을 보여 줍니다. JavaScript를 끄고 시험해 받아들일 만한지 판단하거나, 폼 아래에 영업팀 이메일 링크를 두세요.

### 허니팟과 사람 확인

허니팟이 채워진 경우에도 POST는 되므로 Ikiform이 스팸으로 저장하고, 핸들러는 이를 캘린더 대신 감사 라우트로 보냅니다. 시크릿 키는 Ikiform의 사람 확인을 건너뜁니다. 봇이 폼을 찾아낸다면 [폼에 Turnstile 적용하기](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)를 따라 핸들러에 Turnstile 확인을 추가하세요.

## 렌더링과 배포

라우트는 기본적으로 서버에서 렌더링되며, 미들웨어는 그 요청마다 실행됩니다. 마케팅 페이지를 정적 파일로 사전 렌더링하면 CDN이 미들웨어 없이 제공하므로, 유입 추적은 요청마다 렌더링되는 페이지에서 시작해야 합니다.

| 호스트 | 알아 둘 점 |
| --- | --- |
| Vercel | `nitro/vite` 플러그인을 추가하고 배포하세요. 프로젝트의 환경 변수에 `IKIFORM_SECRET_KEY`를 설정하세요. |
| Netlify | `vite.config.ts`에 `@netlify/vite-plugin-tanstack-start`를 추가하세요. 사이트 설정에서 키를 설정하세요. |
| Cloudflare | `@cloudflare/vite-plugin`을 추가하고 Wrangler로 배포하세요. `wrangler secret put`으로 키를 저장하고, `process.env`에 담기도록 `nodejs_compat`를 켜세요. |

## 작동 확인하기

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

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

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| `src/start.ts`를 추가한 뒤 내 서버 함수가 `403`을 반환함 | CSRF 확인이 호출을 거부했습니다. 프록시 뒤처럼 다른 공개 origin에서 오는 요청에는 `createCsrfMiddleware({ origin })`이 필요합니다. |
| 크로스 사이트 POST가 서버 함수에 도달함 | `src/start.ts`가 있는데 `createCsrfMiddleware()`가 없습니다. 예제처럼 추가하세요. |
| 자격을 갖춘 제출 후에도 페이지가 그대로임 | 서버 함수를 `useServerFn`이 아니라 직접 호출해서, throw된 리디렉션을 따라가는 것이 없습니다. |
| 클라이언트에서 서버 코드를 import한다는 오류 | `getCookie`가 서버 함수나 미들웨어 밖에서 호출되었습니다. `.handler()`나 `.server()` 안에 두세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |

## 질문

### TanStack Start 서버 함수에서 외부 URL로 어떻게 리디렉션하나요?

핸들러 안에서 @tanstack/react-router의 redirect({ href: 'https://...' })를 throw하고, 컴포넌트에서는 useServerFn으로 서버 함수를 호출하세요. useServerFn은 다른 사이트를 포함해 리디렉션을 따라갑니다.

### TanStack Start에서 미들웨어는 어디에 두나요?

전역 요청 미들웨어는 src/start.ts에 두고 createStart에서 requestMiddleware로 반환합니다. SSR, 서버 라우트, 서버 함수보다 먼저 실행됩니다. 이 파일을 만들면 createCsrfMiddleware()를 직접 추가해야 합니다.

### TanStack Start 서버 함수에서 FormData를 어떻게 검증하나요?

.validator()로 입력이 FormData인지 확인하고 일반 객체로 바꾼 다음, 핸들러에서 Zod의 safeParse를 실행하면 필드당 메시지 하나를 반환할 수 있습니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [리드 수집 폼](https://ikiform.com/ko/guides/lead-capture.md): reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.
- [React Router 리드 폼](https://ikiform.com/ko/guides/lead-capture/react-router.md): React Router 프레임워크 모드 리드 폼입니다. 서명된 첫 유입 쿠키를 쓰는 루트 미들웨어, 검증하는 라우트 액션, 스케줄러로의 리디렉션으로 구성합니다.
- [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): 회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
