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

> SvelteKit 리드 폼입니다. handle 훅이 첫 유입 UTM을 기록하고, 폼 액션이 Valibot으로 검증하며, redirect()가 자격을 갖춘 리드를 캘린더로 보냅니다.

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

첫 GET에서 `handle` 훅으로 UTM 태그와 referrer를 httpOnly 쿠키에 기록하세요. 폼 액션에서 Valibot으로 리드를 검증하고, 통과하지 못하면 오류와 값을 담아 `fail()`을 반환하고, 유효한 리드는 유입 정보와 함께 폼 백엔드로 전달하세요. 그런 다음 `redirect(303, url, { external: true })`를 호출해 자격을 갖춘 리드를 스케줄러로 보내세요. `use:enhance`가 다른 사이트로 따라갑니다.

[메인 가이드의 SvelteKit 탭](https://ikiform.com/ko/guides/lead-capture.md#form)은 폼 액션에서 리드를 전달하고 감사 문구를 보여 줍니다. 이 페이지는 영업팀이 원하는 버전을 만듭니다. 서버에서 검증하는 회사 규모, 예산, 일정, 각 리드 뒤의 캠페인, 그리고 적합한 리드를 캘린더로 넘기는 흐름입니다.

## SvelteKit에서 달라지는 점

폼 액션은 SvelteKit에서 POST를 처리하는 기본 방식이며 깔끔하게 점진적으로 동작합니다. JavaScript가 없으면 브라우저가 직접 POST하고 리디렉션을 따라가며, `use:enhance`가 있으면 같은 액션이 `fetch`로 실행됩니다. 다른 origin으로의 리디렉션 결과는 전체 페이지 로드로 처리되므로 Cal.com으로 넘기는 데 추가 클라이언트 코드가 필요 없습니다.

SvelteKit은 모든 서버 요청을 감싸는 `handle` 훅도 제공합니다. 첫 유입을 기록하기에 알맞은 곳입니다. SvelteKit 3는 이 폼과 관련된 두 가지를 바꿨습니다. 다른 사이트로의 리디렉션에는 `{ external: true }`가 필요하고, 시크릿은 `src/env.ts`에 선언해 런타임에 `$app/env/private`에서 읽습니다.

다이어그램: SvelteKit이 액션 결과의 종류별로 하는 일

1. handle 훅이 각 GET을 봅니다. 아직 lead_source 쿠키가 없고 UTM 태그나 외부 referrer가 있으면 쿠키를 설정합니다.
2. SvelteKit이 요청을 처리하고 평소처럼 페이지를 렌더링합니다.
3. 제출 시 use:enhance가 fetch로 폼을 POST하고 처리 중 상태를 설정합니다.
4. 액션이 Valibot으로 검증하고, 쿠키를 읽고, 방문자의 Origin과 함께 리드를 전달합니다.
5. Cal.com으로의 리디렉션은 클라이언트 내비게이션이 될 수 없으므로 SvelteKit이 전체 페이지로 로드합니다.
6. fail 결과는 form prop을 업데이트하고, reset을 false로 준 update가 입력을 그대로 유지합니다.

JavaScript가 없으면 브라우저가 같은 두 가지를 직접 합니다. 303을 따라가거나 form과 함께 페이지를 렌더링합니다.

## 프로젝트 설정

SvelteKit 3 프로젝트에서 시작해 `valibot`을 설치하고 어댑터를 추가하세요. 시크릿 키를 `.env`에 `IKIFORM_SECRET_KEY`로 넣고 `src/env.ts`에 선언하세요. 기본적으로 비공개이고 런타임에 읽히며, SvelteKit은 `$app/env/private`를 클라이언트 코드에서 import하는 것을 거부합니다. 키와 폼은 [SvelteKit 문서 페이지](https://ikiform.com/ko/docs/guides/sveltekit.md)에서 다룹니다.

`파일`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

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

## handle 훅에서 첫 유입 기록하기

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";

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

export const handle: Handle = async ({ event, resolve }) => {
  if (event.request.method === "GET" && !event.cookies.get("lead_source")) {
    const referer = event.request.headers.get("referer") ?? "";
    const from = URL.canParse(referer) ? new URL(referer) : null;
    const external = from && from.host !== event.url.host ? from.href.slice(0, 300) : "";
    const source = new URLSearchParams();
    for (const tag of TAGS) source.set(tag, event.url.searchParams.get(tag)?.slice(0, 100) ?? "");

    if (external || TAGS.some((tag) => source.get(tag))) {
      source.set("referrer", external);
      source.set("landing_page", event.url.pathname.slice(0, 200));
      event.cookies.set("lead_source", source.toString(), {
        path: "/",
        httpOnly: true,
        sameSite: "lax",
        maxAge: 60 * 60 * 24 * 30,
      });
    }
  }
  return resolve(event);
};
```

- GET 요청만 집계하므로 액션 자체의 POST는 쿠키를 설정하지 않습니다.
- SvelteKit 3는 쿠키의 `path` 기본값이 `/`입니다. 명시적으로 설정하면 `path`가 필수인 SvelteKit 2에서도 예제가 동작합니다.
- 내 호스트에서 온 referrer는 무시하므로 내부 클릭이 실제 출처를 대체하지 않습니다.
- 파일로 제공되는 사전 렌더링 페이지에서는 훅이 실행되지 않습니다. 캠페인 랜딩 페이지는 요청 시 렌더링으로 두세요. 그러지 않으면 태그가 훅에 닿지 않습니다.

## 폼 액션

`src/routes/contact-sales/+page.server.ts`:

```ts
import { fail, redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import * as v from "valibot";
import type { Actions } from "./$types";

export const prerender = false;

const Lead = v.object({
  name: v.pipe(v.string(), v.trim(), v.nonEmpty("Tell us who to reply to."), v.maxLength(120)),
  email: v.pipe(v.string(), v.trim(), v.email("Enter a work email we can reply to.")),
  company: v.pipe(v.string(), v.trim(), v.nonEmpty("Which company is this for?"), v.maxLength(200)),
  company_size: v.picklist(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], "Pick a company size."),
  budget: v.picklist(["under_10k", "10k_50k", "50k_plus", "unsure"], "Pick a budget range."),
  timeline: v.picklist(["this_quarter", "next_quarter", "later", "researching"], "Pick a timeline."),
  message: v.pipe(v.string(), v.trim(), v.maxLength(5000, "Keep it under 5,000 characters.")),
  _gotcha: v.string(),
});

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

export const actions = {
  default: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const values = Object.fromEntries(Object.keys(Lead.entries).map((key) => [key, String(form.get(key) ?? "")]));
    const parsed = v.safeParse(Lead, values);
    if (!parsed.success) {
      const nested = v.flatten<typeof Lead>(parsed.issues).nested ?? {};
      const errors = Object.fromEntries(Object.entries(nested).map(([key, list]) => [key, list?.[0] ?? ""]));
      return fail(400, { errors, values });
    }

    const lead = parsed.output;
    const stored = new URLSearchParams(cookies.get("lead_source") ?? "");
    const qualified =
      lead.company_size !== "1_10" &&
      (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
      (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.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] ?? ""]));
      if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
      return fail(response.status >= 400 ? response.status : 502, { errors, values });
    }

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

- Valibot의 `picklist`는 빈 `select`를 내 메시지로 거부하고, `flatten`은 필드마다 메시지 목록 하나를 줍니다.
- 기본 POST 후 페이지에는 값의 다른 사본이 없으므로 `fail()`이 값도 반환합니다. SvelteKit 3는 `fail` 상태를 HTTP 상태로 보냅니다.
- 브라우저의 `Origin` 헤더를 전달합니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더로 무료 `localhost` 테스트를 판별하기 때문입니다.
- `redirect()`는 예외를 던지므로 그 뒤의 코드는 실행되지 않습니다. SvelteKit 2부터는 `throw` 없이 호출하고, SvelteKit 3부터 다른 origin의 대상에는 `{ external: true }`가 필요합니다. 없으면 리디렉션 대신 액션이 실패합니다.
- 액션이 있는 페이지는 사전 렌더링할 수 없습니다. 상위 레이아웃이 사전 렌더링을 켤 경우에 대비해 `prerender = false`로 명시합니다.

## 페이지

`src/routes/contact-sales/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let pending = $state(false);
  const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
  const values = $derived<Record<string, string | undefined>>(form?.values ?? {});

  const selects = [
    { id: "company_size", label: "Company size", options: [["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"]] },
    { id: "budget", label: "Budget", options: [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]] },
    { id: "timeline", label: "When do you want to start?", options: [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]] },
  ];
</script>

<form
  method="POST"
  use:enhance={() => {
    pending = true;
    return async ({ update }) => {
      await update({ reset: false });
      pending = false;
    };
  }}
>
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required value={values.name ?? ""} />
  {#if errors.name}<p role="alert">{errors.name}</p>{/if}

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required value={values.email ?? ""} />
  {#if errors.email}<p role="alert">{errors.email}</p>{/if}

  <label for="company">Company</label>
  <input id="company" name="company" autocomplete="organization" required value={values.company ?? ""} />
  {#if errors.company}<p role="alert">{errors.company}</p>{/if}

  {#each selects as select (select.id)}
    <label for={select.id}>{select.label}</label>
    <select id={select.id} name={select.id} required value={values[select.id] ?? ""}>
      <option value="" disabled>Choose one</option>
      {#each select.options as [value, text] (value)}
        <option {value}>{text}</option>
      {/each}
    </select>
    {#if errors[select.id]}<p role="alert">{errors[select.id]}</p>{/if}
  {/each}

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

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  {#if errors.form}<p role="alert">{errors.form}</p>{/if}
  <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
```

`use:enhance`는 `update()`로 기본 동작을 실행하지만, `reset: false`가 실패 후에도 입력을 유지합니다. JavaScript가 없으면 액션의 `fail` 데이터가 `form` prop으로 돌아오고 `value` 속성이 모든 필드를 다시 채웁니다.

### 허니팟과 사람 확인

허니팟은 스키마에 있으므로 Ikiform에 도달하고 값이 채워지면 스팸으로 저장되며, 액션은 이를 감사 페이지로 보냅니다. SvelteKit 내장 CSRF 확인이 이미 다른 origin의 폼 POST를 거부합니다. 시크릿 키를 쓰면 Ikiform의 사람 확인을 건너뛰므로, 필요하다면 [폼에 Turnstile 적용하기](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)처럼 액션에서 Turnstile 토큰을 검증하세요.

## 배포 참고 사항

| 호스트 | 알아 둘 점 |
| --- | --- |
| Vercel | `@sveltejs/adapter-vercel` 또는 `adapter-auto`. 프로젝트의 환경 변수에 `IKIFORM_SECRET_KEY`를 추가하세요. |
| Netlify | `@sveltejs/adapter-netlify`. 사이트 설정에서 키를 설정하세요. |
| Cloudflare | `@sveltejs/adapter-cloudflare`. `wrangler secret put`으로 키를 저장하세요. 런타임에만 존재하지만, 선언된 변수는 앱이 시작될 때 읽히므로 `$app/env/private`가 처리합니다. |

## 작동 확인하기

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

1. `vite dev`를 실행하고 `/?utm_source=test&utm_medium=email`을 연 뒤 개발자 도구에서 `lead_source` 쿠키를 확인하세요.
2. `/contact-sales`에서 회사 규모를 고르고 **Budget**(예산)을 비운 채 제출하세요. 예산 오류가 표시되고 다른 답은 유지됩니다.
3. 자격을 갖춘 리드로 제출하세요. 201~1,000명, $10k~$50k, 이번 분기입니다. 이름과 이메일이 채워진 Cal.com이 로드됩니다.
4. **Later this year**(올해 하반기)로 제출하세요. `/contact-sales/thanks`로 이동합니다.
5. 대시보드에서 **Submissions**를 열고 `utm_medium`, `landing_page`, `fit`을 확인하세요.
6. 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에 `email` 필드가 있으면 **Reply**는 제출자에게 갑니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| `403 Cross-site POST form submissions are forbidden` | SvelteKit의 CSRF 확인이 맞지 않는 origin을 보았습니다. 호스트를 전달하지 않는 프록시 뒤라면 `vite.config.ts`의 `sveltekit()` 옵션에 `paths.origin`을 설정하세요. SvelteKit 3는 adapter-node의 `ORIGIN` 변수를 제거했습니다. 내 다른 사이트라면 `csrf.trustedOrigins`에 추가하세요. |
| Cal.com으로의 리디렉션이 외부 URL 관련 오류로 실패함 | SvelteKit 3는 기본적으로 외부 리디렉션을 거부합니다. 세 번째 인자로 `{ external: true }`나 `{ external: ["https://cal.com"] }` 같은 허용 목록을 넘기세요. |
| 페이지가 Cal.com으로 이동하지 않고 콘솔에 오류가 표시됨 | 커스텀 `use:enhance` 콜백이 리디렉션에 `goto()`를 호출했습니다. `goto`는 외부 URL을 거부합니다. 대신 `update()`나 `applyAction()`을 호출하세요. |
| 오류 후 필드가 비어 버림 | `update()`를 `reset: false` 없이 호출했거나, 입력이 `form.values`를 읽지 않습니다. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |

## 질문

### SvelteKit 폼 액션에서 외부 URL로 리디렉션할 수 있나요?

네. 액션에서 redirect(303, 'https://...', { external: true })를 호출하세요. SvelteKit 3는 이 옵션 없는 외부 대상을 거부합니다. JavaScript가 없으면 브라우저가 따라가고, use:enhance가 있으면 URL이 앱에 속하지 않으므로 SvelteKit이 전체 페이지 로드로 처리합니다.

### SvelteKit 3에서 시크릿 키를 어떻게 읽나요?

src/env.ts에서 defineEnvVars로 선언하고 $app/env/private에서 import하세요. static: true를 설정하지 않으면 런타임에 읽히므로 Cloudflare Workers 시크릿처럼 런타임에만 있는 시크릿도 동작합니다. $env 모듈도 여전히 동작하지만 deprecated입니다.

### SvelteKit 액션이 실패한 뒤 폼 값을 어떻게 유지하나요?

fail(400, { errors, values })로 반환하고 각 입력의 value를 form.values에 연결하세요. use:enhance에서는 폼이 지워지지 않도록 update({ reset: false })를 호출하세요.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [리드 수집 폼](https://ikiform.com/ko/guides/lead-capture.md): reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.
- [Nuxt 리드 폼](https://ikiform.com/ko/guides/lead-capture/nuxt.md): Nuxt 리드 폼입니다. 서버 미들웨어가 첫 유입 UTM을 기록하고, Zod로 검증하는 서버 라우트 하나가 fetch와 일반 POST를 모두 처리하며, 좋은 리드는 통화를 예약합니다.
- [Astro 리드 폼](https://ikiform.com/ko/guides/lead-capture/astro.md): Actions로 만든 Astro 리드 폼입니다. Zod 검증, 정적 사이트에서의 첫 유입 UTM, 요청 시 렌더링되는 문의 페이지, 스케줄러로의 리디렉션을 다룹니다.
- [데모 요청 폼](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): 회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
