새 SaaS용 대기자 명단5분 분량
SvelteKit으로 대기자 명단 만들기
SvelteKit 3 대기자 명단: 훅이 첫 ?ref=를 쿠키에 보관하고, 이름 있는 폼 액션이 가입을 전달하며, 재방문자는 이미 가입했음을 확인합니다.
방문자의 첫 요청에서 hooks.server.ts로 ?ref=를 읽어 쿠키에 보관하면, 방문자가 여기저기 둘러본 뒤에도 가입이 처음 데려온 링크의 공으로 기록됩니다. 이름 있는 폼 액션이 쿠키를 읽어 시크릿 키로 가입을 폼 백엔드에 전달하고, 두 번째 쿠키를 설정해 재방문자에게 다시 묻는 대신 인사를 건넵니다.
메인 가이드에는 기본 액션과 use:enhance를 쓰고 ref를 homepage로 고정한 SvelteKit 탭이 있습니다. 이 페이지는 실제 출시에 필요한 것을 더합니다. 방문자는 뉴스레터를 보고 블로그 글로 들어와 한동안 읽다가 두 페이지 뒤에 가입합니다. 그 가입은 이메일을 입력한 페이지가 아니라 뉴스레터의 공으로 기록되어야 합니다.
SvelteKit에서 다른 점
SvelteKit의 두 기능이 여기에 잘 맞고, 규칙 하나가 구성을 결정합니다.
| 기능 | 여기서 하는 일 |
|---|---|
hooks.server.ts의 handle | 서버 렌더링 요청마다 먼저 실행되므로, 방문자가 어느 페이지로 들어오든 ?ref=를 볼 수 있음 |
event.cookies | 첫 유입 경로를 30일 동안 저장. 액션이 다시 읽으며, 조작할 숨겨진 입력이 없음 |
use:enhance를 쓰는 폼 액션 | 서버 코드 하나로 JavaScript 없이 POST하고, 로드되면 기능을 개선 |
| 규칙 | 액션이 있는 페이지는 서버가 POST에 응답해야 하므로 프리렌더링할 수 없음 |
숨겨진 입력으로는 할 수 없습니다. 폼이 있는 페이지만 알고, URL의 태그는 첫 클릭 후 사라집니다. 서버에서 설정한 쿠키는 모든 페이지에서 유지되고, 방문자가 보거나 수정할 수 없습니다.
이것은 첫 접점 기여 방식입니다. 처음 태그된 링크가 이깁니다. 나중에 다른 링크로 방문해도 덮어쓰지 않습니다. 마지막 링크의 공으로 기록하고 싶다면 !event.cookies.get(...) 검사를 빼세요.
- 방문자가 ?ref=news가 붙은 링크로 블로그 글에 들어옵니다.
- handle 훅이 태그를 보고, 아직 waitlist_ref 쿠키가 없으므로 30일짜리 쿠키를 설정합니다.
- 방문자가 요금제를 읽고 홈 페이지로 돌아옵니다. 두 URL 모두 태그가 없지만 쿠키에는 남아 있습니다.
- 가입합니다. join 액션이 waitlist_ref를 읽고 ref를 news로 설정해 가입을 Ikiform에 전달합니다.
- 성공하면 액션이 waitlist_joined를 설정하므로, 다음 방문 때 홈 페이지가 폼 대신 감사 문구를 보여 줍니다.
프로젝트 설정하기
SvelteKit 문서 가이드처럼 폼과 시크릿 키를 만드세요. 이 페이지는 SvelteKit 3을 사용합니다. 파일은 다음과 같습니다.
src/
env.ts
hooks.server.ts
routes/
+page.server.ts
+page.svelte
blog/[slug]/+page.svelte
vite.config.ts
.env시크릿 선언하기
SvelteKit 3은 선언된 변수를 쓰도록 $env/... 모듈을 지원 중단했습니다. 키를 src/env.ts에 선언하세요. 변수는 공개로 표시하지 않는 한 비공개이며, $app/env/private는 브라우저 코드에서 가져올 수 없습니다.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.액션은 email과 ref를 보냅니다. 폼의 Fields 탭에 필드 정의가 있다면 둘 다 넣으세요.
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}만들기
유입 경로 기록하기
import type { Handle } from "@sveltejs/kit/hooks";
const SOURCE = /^[a-z0-9_-]{1,40}$/;
const THIRTY_DAYS = 60 * 60 * 24 * 30;
export const handle: Handle = async ({ event, resolve }) => {
const query = event.url.searchParams;
const tag = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase();
if (SOURCE.test(tag) && !event.cookies.get("waitlist_ref")) {
event.cookies.set("waitlist_ref", tag, { maxAge: THIRTY_DAYS, httpOnly: true, sameSite: "lax" });
}
return resolve(event);
};- 태그는 소문자로 바뀌고 Ikiform의
max_length가 허용하는 같은 패턴과 일치해야 하므로, 조작된 링크가 쓰레기 값을 저장할 수 없습니다. httpOnly는 페이지 스크립트가 쿠키에 접근하지 못하게 합니다.sameSite: "lax"여도 폼 자신의POST에는 쿠키가 전송됩니다.- SvelteKit 3에서 쿠키의
path기본값은/이므로, 따로 설정하지 않아도 모든 라우트에 전달됩니다.
이름 있는 액션
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions, PageServerLoad } from "./$types";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const YEAR = 60 * 60 * 24 * 365;
export const load: PageServerLoad = ({ cookies }) => ({
joined: cookies.get("waitlist_joined") === "yes",
});
export const actions = {
join: async ({ request, cookies, fetch, url }) => {
const form = await request.formData();
const email = String(form.get("email") ?? "").trim();
if (!EMAIL.test(email)) return fail(400, { email, message: "Enter the email we should send your invite to." });
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({
email,
ref: cookies.get("waitlist_ref") ?? "direct",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
if (!response) return fail(504, { email, message: "We couldn't reach the list. Try again." });
const result = await response.json().catch(() => null);
if (!result?.ok) {
return fail(response.status === 429 ? 429 : 400, {
email,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
});
}
cookies.set("waitlist_joined", "yes", { maxAge: YEAR, httpOnly: true, sameSite: "lax" });
return { joined: true };
},
} satisfies Actions;- 이름 있는 액션
join을 쓰면 나중에 이 페이지에 다른 액션을 추가할 여지가 생깁니다. SvelteKit은 이름 있는 액션과 기본 액션을 함께 쓰는 것을 허용하지 않습니다. - Ikiform은 브라우저의
Origin헤더로 무료localhost테스트 제출을 세는데 액션 자체의fetch는 이 헤더를 보내지 않으므로, 이를 전달합니다. fail()은 입력한 이메일과 메시지를 반환합니다. SvelteKit 3에서 개선된 제출은fail()에 넘긴 상태 코드를 사용하므로page.status는400또는429가 됩니다.load가waitlist_joined를 읽으므로 재방문자는 이미 가입했음을 알 수 있습니다. 브라우저 하나에 대한 배려일 뿐 중복 제거는 아닙니다. 다른 기기에서 다시 가입하면 Ikiform은 여전히 두 번째 가입을 저장합니다.
페이지
<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { data, form }: PageProps = $props();
let pending = $state(false);
</script>
<h1>Acme is almost here</h1>
{#if data.joined || form?.joined}
<p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
<form
method="POST"
action="?/join"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{form.message}</p>
{/if}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
{/if}action="?/join"이 액션 이름을 지정합니다. JavaScript가 없으면 브라우저가 그곳으로 POST하고, SvelteKit이form이 설정된 페이지를 렌더링합니다.use:enhance는 페이지가 하이드레이션되면fetch로 POST합니다.update({ reset: false })는 오류 후에도 이메일을 입력란에 남겨 두므로, 방문자가 다시 입력하지 않고 오타만 고칠 수 있습니다.pending은 액션이 응답할 때까지 버튼을 비활성화해 느린 휴대폰에서 두 번 POST되는 것을 막습니다.data.joined는load에서,form.joined는 액션에서 옵니다. 둘 다 확인하면 조건 하나로 재방문자와 방금 가입한 사람을 모두 처리합니다.
허니팟은 손대지 않은 채 Ikiform에 도달하므로, 이를 채운 봇은 스팸으로 저장됩니다. 시크릿 키를 쓴 POST는 Ikiform의 사람 확인을 건너뛰므로 Spam check를 켜도 여기서는 아무 효과가 없습니다.
프리렌더링과 배포
홈 페이지에는 액션이 있으므로 서버에서 렌더링해야 합니다. 블로그 글 같은 콘텐츠는 export const prerender = true로 프리렌더링할 수 있지만, 방문자가 프리렌더링된 파일을 열 때는 handle이 실행되지 않습니다. 빌드 시점에 한 번 실행되었을 뿐입니다. 프리렌더링된 페이지로 바로 가는 태그 링크는 쿠키를 설정하지 않습니다. 캠페인 링크는 홈 페이지처럼 서버에서 렌더링되는 페이지를 가리키게 하세요.
| 호스트 | 어댑터 | 참고 |
|---|---|---|
| Vercel | @sveltejs/adapter-vercel | SvelteKit 3에서는 여기서 edge 런타임이 빠졌으므로 액션은 Node 함수에서 실행됨 |
| Netlify | @sveltejs/adapter-netlify | Environment variables에서 키 설정 |
| Cloudflare | @sveltejs/adapter-cloudflare | 어댑터의 피어 의존성인 wrangler 4.118 이상이 필요. wrangler secret put IKIFORM_SECRET_KEY로 키 설정 |
작동 확인하기
vite dev를 실행하고http://localhost:5173/?ref=test를 연 뒤, 쿼리를 지우고 새로 고치세요. 브라우저 개발자 도구에서waitlist_ref쿠키가 여전히test입니다.- 가입하세요. 새로 고침 없이 감사 문구가 폼을 대신하고, Submissions의 가입에는
ref가test로 설정되어 있습니다. - 페이지를 새로 고치세요. 폼이 아니라 감사 문구가 표시됩니다.
- 쿠키를 지우고 JavaScript를 끈 뒤
a@b를 제출하세요. 오류와 함께 입력란에 주소가 남은 페이지가 돌아옵니다. - 허니팟을 채워서
_gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. vite dev에서 보낸 POST는localhost출처를 가지므로 테스트 제출로 집계됩니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도localhost에서 보낸 테스트 제출을 매달 50건 받습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 내세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
주의할 점
| 문제 | 해결 방법 |
|---|---|
| 액션이 있는 페이지는 프리렌더링할 수 없다며 빌드가 실패함 | 레이아웃이 모든 라우트에 prerender = true를 설정합니다. 대기자 명단 페이지의 +page.server.ts에 export const prerender = false를 설정하세요. |
ref가 항상 direct임 | 태그 링크가 프리렌더링된 페이지를 가리켜서 handle이 보지 못했거나, 이전 방문에서 이미 쿠키가 설정되었습니다. 개발자 도구에서 쿠키를 확인하세요. |
업그레이드 후 $env/static/private 경고 | SvelteKit 3은 $env 모듈을 지원 중단했습니다. 키를 src/env.ts에 선언하고 $app/env/private에서 가져오세요. |
unknown_field와 함께 422 | 필드 정의에 ref가 없습니다. 위의 정의처럼 추가하세요. |
402 plan_required | 워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. Settings › Plan and usage에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |
질문
첫 요청 때 handle 훅에서 쿼리를 읽어 태그를 httpOnly 쿠키에 저장하고, 폼 액션에서 쿠키를 읽으세요. 유입 경로는 페이지를 이동해도 유지되며 폼에 나타나지 않습니다.
아니요. 액션이 있는 페이지는 POST에 응답할 서버가 필요하므로 SvelteKit이 프리렌더링을 거부합니다. 다른 페이지를 프리렌더링하고 폼 페이지는 서버 렌더링으로 두세요.
그 브라우저에서만 막습니다. Ikiform은 이메일 기준으로 중복을 제거하지 않으므로, 목록이 있는 곳이나 내보낼 때 반복을 지우세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.