리드 수집 폼6분 분량
SvelteKit으로 자격 확인 리드 폼 만들기
SvelteKit 리드 폼입니다. handle 훅이 첫 유입 UTM을 기록하고, 폼 액션이 Valibot으로 검증하며, redirect()가 자격을 갖춘 리드를 캘린더로 보냅니다.
첫 GET에서 handle 훅으로 UTM 태그와 referrer를 httpOnly 쿠키에 기록하세요. 폼 액션에서 Valibot으로 리드를 검증하고, 통과하지 못하면 오류와 값을 담아 fail()을 반환하고, 유효한 리드는 유입 정보와 함께 폼 백엔드로 전달하세요. 그런 다음 redirect(303, url, { external: true })를 호출해 자격을 갖춘 리드를 스케줄러로 보내세요. use:enhance가 다른 사이트로 따라갑니다.
메인 가이드의 SvelteKit 탭은 폼 액션에서 리드를 전달하고 감사 문구를 보여 줍니다. 이 페이지는 영업팀이 원하는 버전을 만듭니다. 서버에서 검증하는 회사 규모, 예산, 일정, 각 리드 뒤의 캠페인, 그리고 적합한 리드를 캘린더로 넘기는 흐름입니다.
SvelteKit에서 달라지는 점
폼 액션은 SvelteKit에서 POST를 처리하는 기본 방식이며 깔끔하게 점진적으로 동작합니다. JavaScript가 없으면 브라우저가 직접 POST하고 리디렉션을 따라가며, use:enhance가 있으면 같은 액션이 fetch로 실행됩니다. 다른 origin으로의 리디렉션 결과는 전체 페이지 로드로 처리되므로 Cal.com으로 넘기는 데 추가 클라이언트 코드가 필요 없습니다.
SvelteKit은 모든 서버 요청을 감싸는 handle 훅도 제공합니다. 첫 유입을 기록하기에 알맞은 곳입니다. SvelteKit 3는 이 폼과 관련된 두 가지를 바꿨습니다. 다른 사이트로의 리디렉션에는 { external: true }가 필요하고, 시크릿은 src/env.ts에 선언해 런타임에 $app/env/private에서 읽습니다.
- handle 훅이 각 GET을 봅니다. 아직 lead_source 쿠키가 없고 UTM 태그나 외부 referrer가 있으면 쿠키를 설정합니다.
- SvelteKit이 요청을 처리하고 평소처럼 페이지를 렌더링합니다.
- 제출 시 use:enhance가 fetch로 폼을 POST하고 처리 중 상태를 설정합니다.
- 액션이 Valibot으로 검증하고, 쿠키를 읽고, 방문자의 Origin과 함께 리드를 전달합니다.
- Cal.com으로의 리디렉션은 클라이언트 내비게이션이 될 수 없으므로 SvelteKit이 전체 페이지로 로드합니다.
- fail 결과는 form prop을 업데이트하고, reset을 false로 준 update가 입력을 그대로 유지합니다.
프로젝트 설정
SvelteKit 3 프로젝트에서 시작해 valibot을 설치하고 어댑터를 추가하세요. 시크릿 키를 .env에 IKIFORM_SECRET_KEY로 넣고 src/env.ts에 선언하세요. 기본적으로 비공개이고 런타임에 읽히며, SvelteKit은 $app/env/private를 클라이언트 코드에서 import하는 것을 거부합니다. 키와 폼은 SvelteKit 문서 페이지에서 다룹니다.
src/
env.ts
hooks.server.ts
routes/
contact-sales/
+page.server.ts
+page.svelte
thanks/+page.svelte
vite.config.ts
.envimport { 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 | 서버, 검증 후 |
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.handle 훅에서 첫 유입 기록하기
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는 무시하므로 내부 클릭이 실제 출처를 대체하지 않습니다.
- 파일로 제공되는 사전 렌더링 페이지에서는 훅이 실행되지 않습니다. 캠페인 랜딩 페이지는 요청 시 렌더링으로 두세요. 그러지 않으면 태그가 훅에 닿지 않습니다.
폼 액션
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로 명시합니다.
페이지
<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 적용하기처럼 액션에서 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를 구매하세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
vite dev를 실행하고/?utm_source=test&utm_medium=email을 연 뒤 개발자 도구에서lead_source쿠키를 확인하세요./contact-sales에서 회사 규모를 고르고 Budget(예산)을 비운 채 제출하세요. 예산 오류가 표시되고 다른 답은 유지됩니다.- 자격을 갖춘 리드로 제출하세요. 201~1,000명, $10k~$50k, 이번 분기입니다. 이름과 이메일이 채워진 Cal.com이 로드됩니다.
- Later this year(올해 하반기)로 제출하세요.
/contact-sales/thanks로 이동합니다. - 대시보드에서 Submissions를 열고
utm_medium,landing_page,fit을 확인하세요. - 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에
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와 맞추세요. |
질문
네. 액션에서 redirect(303, 'https://...', { external: true })를 호출하세요. SvelteKit 3는 이 옵션 없는 외부 대상을 거부합니다. JavaScript가 없으면 브라우저가 따라가고, use:enhance가 있으면 URL이 앱에 속하지 않으므로 SvelteKit이 전체 페이지 로드로 처리합니다.
src/env.ts에서 defineEnvVars로 선언하고 $app/env/private에서 import하세요. static: true를 설정하지 않으면 런타임에 읽히므로 Cloudflare Workers 시크릿처럼 런타임에만 있는 시크릿도 동작합니다. $env 모듈도 여전히 동작하지만 deprecated입니다.
fail(400, { errors, values })로 반환하고 각 입력의 value를 form.values에 연결하세요. use:enhance에서는 폼이 지워지지 않도록 update({ reset: false })를 호출하세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.