뉴스레터 가입 폼6분 분량
SvelteKit으로 뉴스레터 가입 폼 만들기
레이아웃에 둔 SvelteKit 3 뉴스레터 폼입니다. 동의와 IP 한도가 있는 폼 액션, 페이지에 머무는 use:enhance, Kit 웹훅 라우트로 구성됩니다.
폼 액션을 /newsletter 페이지에 두고 루트 레이아웃의 폼에서 그곳으로 POST하세요. update({ navigate: false })와 함께 쓰는 use:enhance는 독자가 있는 페이지에 결과를 적용하므로 푸터가 글을 떠나지 않고 결과를 보여 줍니다. JavaScript가 없으면 같은 POST가 /newsletter로 이동합니다. 액션은 동의를 확인하고 getClientAddress()로 IP마다 한도를 두고 가입을 폼 백엔드로 전달하며, +server.ts 라우트가 검증된 가입을 Kit으로 넘깁니다.
메인 가이드의 SvelteKit 탭은 푸터에서 /newsletter 액션으로 POST하고 결과를 직접 처리합니다. 이 페이지는 SvelteKit 3 방식으로 만듭니다. 명시적 환경 변수, 프레임워크가 결과를 적용하는 update({ navigate: false }), IP별 한도, Kit용 웹훅 라우트입니다. 제품 설정은 SvelteKit 문서 가이드에 있습니다.
SvelteKit에서 레이아웃 폼이 바꾸는 것
폼 액션은 +page.server.ts에만 있고 레이아웃에는 둘 수 없으므로, 푸터 폼은 다른 페이지의 액션으로 POST합니다. SvelteKit 3은 여기서 향상된 폼이 네이티브 폼처럼 동작하게 했습니다. 액션이 다른 페이지에 있는 폼은 그 페이지로 이동합니다. 문서가 이를 끄는 경우로 꼽는 것이 바로 뉴스레터 위젯이며, update({ navigate: false })로 끕니다. 그러면 결과가 현재 페이지에 page.form으로 적용되고 푸터가 이를 읽습니다.
- 독자가 푸터 폼을 제출합니다. use:enhance가 백그라운드에서 /newsletter의 subscribe 액션으로 보냅니다.
- 액션이 동의 체크박스를 확인한 뒤 독자 IP의 최근 시도를 셉니다.
- 한도를 넘으면 fail(429)을 반환하고 아무것도 보내지 않습니다.
- 그렇지 않으면 시크릿 키와 브라우저의 Origin과 함께 가입을 Ikiform으로 POST합니다.
- navigate false로 호출한 update가 결과를 현재 페이지에 page.form으로 적용하고 푸터가 이를 보여 줍니다. JavaScript가 없으면 브라우저가 같은 컴포넌트를 렌더링하는 /newsletter로 이동합니다.
프로젝트 설정
src/
env.ts
lib/
NewsletterForm.svelte
server/limit.ts
routes/
+layout.svelte
newsletter/
+page.server.ts
+page.svelte
api/ikiform/+server.ts
.envSvelteKit 3은 $env/* 모듈을 명시적 변수로 대체하며 지원 중단합니다. src/env.ts에 선언하세요. 기본으로 비공개이고 런타임에 읽히며, $app/env/private는 브라우저 코드에 import되는 것을 거부합니다.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
IKIFORM_WEBHOOK_SECRET: {},
KIT_API_KEY: {},
KIT_FORM_ID: {},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567API keys에서 submissions:write 스코프로 Ikiform 키를 만들고, 폼의 Fields 탭에 필러의 필드 정의를 붙여 넣으세요.
만들기
IP별 제한
사람은 한 번 구독합니다. 10분에 세 번이면 오타를 고칠 여유가 있고 스크립트는 막습니다. getClientAddress()는 어댑터가 판별한 IP를 반환하므로 리미터는 호스트가 어떤 헤더를 쓰는지 알 필요가 없습니다.
const WINDOW_MS = 10 * 60_000;
const LIMIT = 3;
const recent = new Map<string, number[]>();
export function allow(address: string, now = Date.now()) {
const hits = (recent.get(address) ?? []).filter((time) => now - time < WINDOW_MS);
if (hits.length >= LIMIT) return false;
if (recent.size > 10_000) recent.clear();
recent.set(address, [...hits, now]);
return true;
}카운터는 한 서버의 메모리에 있습니다. 서버리스 호스트에서는 인스턴스마다 따로 세므로 과속 방지턱 정도로 여기고, 트래픽이 늘면 공유 저장소로 옮기세요. server 디렉터리의 파일은 브라우저 코드에서 import할 수 없습니다. SvelteKit 3은 새 프로젝트가 package.json의 imports에 선언하는 #lib 별칭으로 src/lib에 접근합니다. 예전의 $lib 별칭은 없어졌습니다.
액션
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { allow } from "#lib/server/limit.js";
import type { Actions } from "./$types";
export const prerender = false;
const PLACEMENTS = ["footer", "post", "newsletter-page"];
export const actions = {
subscribe: async ({ request, fetch, getClientAddress, url }) => {
const form = await request.formData();
const email = String(form.get("email") ?? "").trim();
const picked = String(form.get("source") ?? "");
const source = PLACEMENTS.includes(picked) ? picked : "footer";
const refuse = (status: number, field: string, message: string) =>
fail(status, { newsletter: { ok: false, source, field, message, email } });
if (form.get("consent") !== "true") return refuse(422, "consent", "Tick the box so we know you want the emails.");
if (!allow(getClientAddress())) return refuse(429, "form", "Too many tries from this connection. Wait a few minutes.");
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,
first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
consent: true,
source,
_gotcha: String(form.get("_gotcha") ?? ""),
}),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { newsletter: { ok: true, source } };
const emailError = result?.error?.fields?.email?.[0];
if (emailError) return refuse(422, "email", emailError);
return refuse(response?.status ?? 502, "form", result?.error?.message ?? "We couldn't reach the list. Try again.");
},
} satisfies Actions;- 결과는
newsletter로 감싸므로page.form에서 다른 폼의 결과와 겹치지 않습니다. fail()은 입력한 이메일을 유지합니다. SvelteKit 3은 향상된 폼에 대해fail()의 상태도 전송하므로 네트워크 탭에422나429가 표시됩니다.Origin줄은 브라우저의 origin을 전달합니다. Ikiform은 이 헤더로 무료localhost테스트를 알아보는데, 서버의fetch는 이를 보내지 않기 때문입니다.- 루트 레이아웃이 모든 것을 사전 렌더링한다면
prerender = false가 중요합니다. 액션이 있는 페이지는 사전 렌더링할 수 없습니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.폼 컴포넌트
<script lang="ts">
import { enhance } from "$app/forms";
import { page } from "$app/state";
type Outcome = { ok: boolean; source: string; field?: string; message?: string; email?: string };
let { source = "footer" }: { source?: "footer" | "post" | "newsletter-page" } = $props();
let pending = $state(false);
const outcome = $derived.by(() => {
const value = page.form?.newsletter as Outcome | undefined;
return value?.source === source ? value : undefined;
});
</script>
{#if outcome?.ok}
<p role="status">Almost done. Open the email we sent you and confirm your address.</p>
{:else}
<form
method="POST"
action="/newsletter?/subscribe"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ navigate: false, refreshAll: false, reset: false });
pending = false;
};
}}
>
<label>
Email
<input name="email" type="email" required autocomplete="email" value={outcome?.email ?? ""} aria-invalid={outcome?.field === "email"} />
</label>
<label>
First name (optional)
<input name="first_name" maxlength="80" autocomplete="given-name" />
</label>
<label>
<input name="consent" type="checkbox" value="true" required aria-invalid={outcome?.field === "consent"} />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if outcome?.message}
<p role="alert">{outcome.message}</p>
{/if}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
{/if}navigate: false는 독자를 글에 머물게 합니다.refreshAll: false는 가입 성공 뒤 모든load함수를 다시 실행하지 않게 합니다. 가입은 그들이 불러오는 것을 아무것도 바꾸지 않습니다.reset: false는 오류가 났을 때 입력한 이메일을 유지합니다. 성공하면 어차피 폼이 바뀝니다.- JavaScript가 꺼져 있으면 POST가 이 같은 컴포넌트를 렌더링하는
/newsletter로 가고,page.form이 그곳에 결과를 담습니다.
/newsletter 페이지
<script lang="ts">
import NewsletterForm from "#lib/NewsletterForm.svelte";
</script>
<svelte:head>
<title>Newsletter</title>
</svelte:head>
<h1>The monthly newsletter</h1>
<p>What we shipped, what broke and what we learned. One email a month.</p>
<NewsletterForm source="newsletter-page" />푸터용으로 +layout.svelte에서 <NewsletterForm />을 렌더링하고, 글 끝에서는 source="post"를 넘기세요.
구독자를 Kit로 전달하기
이 +server.ts 라우트는 Ikiform의 서명된 웹훅을 받아 Kit에 비활성 구독자를 만들고 Kit 폼에 추가합니다. 그 폼의 인센티브 이메일이 Kit의 확인 이메일이므로, 본인 주소로 한 번 테스트하세요. 서명은 정확한 바이트를 대상으로 하므로 본문을 먼저 텍스트로 읽고, SvelteKit 3이 json()과 text() 헬퍼를 지원 중단하므로 일반 Response 객체를 반환합니다. Kit 가이드는 Kit의 각 응답을 설명하고 태그를 추가합니다.
import { IKIFORM_WEBHOOK_SECRET, KIT_API_KEY, KIT_FORM_ID } from "$app/env/private";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, fetch }) => {
let event;
try {
event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { email, first_name, consent } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const kit = (path: string, body: Record<string, unknown>) =>
fetch(`https://api.kit.com/v4${path}`, {
method: "POST",
headers: { "X-Kit-Api-Key": KIT_API_KEY, "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
const created = await kit("/subscribers", {
email_address: email,
first_name: typeof first_name === "string" ? first_name : null,
state: "inactive",
});
const added = created.ok ? await kit(`/forms/${KIT_FORM_ID}/subscribers`, { email_address: email }) : created;
if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
return new Response(null, { status: 204 });
};SvelteKit의 CSRF 보호는 버전 3에서 항상 켜져 있지만, 크로스 오리진 폼 제출과 Content-Type이 없는 크로스 오리진 POST만 거부합니다. Ikiform은 application/json을 보내므로 웹훅은 통과합니다. Webhooks에 URL을 추가하고 whsec_ 시크릿을 복사하세요.
허니팟과 사람 확인
_gotcha 입력은 전달되고, Ikiform은 값이 채워진 것을 성공 응답과 함께 웹훅 없이 스팸으로 저장하므로 Kit에 닿지 않습니다. 시크릿 키는 Ikiform의 Turnstile 확인을 건너뜁니다. 스크립트가 반복하는 경우는 위의 IP 한도가 막습니다. 여러 IP를 쓰는 봇에는 Turnstile 가이드처럼 액션에서 Turnstile 토큰을 검증하세요.
배포
| 호스트 | 해결 방법 |
|---|---|
| Vercel | adapter-vercel은 액션을 함수로 실행합니다. SvelteKit 3은 그곳에서 edge 런타임을 없앴으므로 기본값을 쓰세요. Environment Variables에 변수를 설정하세요. |
| Netlify | adapter-netlify는 Netlify의 Frameworks API를 따르며, 이제 publish 디렉터리는 어댑터 옵션입니다. 사이트의 환경 설정에 변수를 설정하세요. |
| Cloudflare | adapter-cloudflare는 Workers용으로 빌드하며 peer dependency인 Wrangler 4.118 이상이 필요합니다. 문서는 변수에 $app/env를 권합니다. 웹훅에는 커스텀 도메인을 쓰세요. Ikiform은 workers.dev와 pages.dev를 거부합니다. |
localhost에서 무료로 테스트하기
플랜이 없는 워크스페이스는 localhost에서 월 50건의 테스트 제출을 받고, 전달되는 Origin 덕분에 개발 서버의 각 가입이 그중 하나로 집계됩니다. 리미터는 나도 세므로 테스트 중 한도에 걸리면 개발 서버를 재시작하세요. Ikiform은 localhost로 웹훅을 전달하지 않습니다. 라우트를 테스트하려면 HTTPS URL이 있는 터널로 개발 서버를 노출하세요.
작동 확인하기
vite dev를 실행하고 아무 페이지나 열어 푸터에서 구독하세요. 푸터에 문구가 나타나고 URL은 바뀌지 않습니다.- JavaScript를 끄고 다시 구독하세요. 같은 문구가 있는
/newsletter로 이동합니다. - 10분 안에 네 번 제출하세요. 네 번째에 한도 메시지가 나오고 네트워크 탭에
429가 표시됩니다. - 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
- 웹훅을 추가했다면 Webhooks에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께
succeeded로 표시됩니다. - Kit를 여세요. 구독자가 inactive로 있고 Kit의 확인 이메일이 받은편지함에 있습니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
JavaScript가 있는데도 푸터 폼을 제출하면 /newsletter로 이동함 | use:enhance 콜백이 navigate: false 없이 update()를 호출합니다. SvelteKit 3에서는 다른 페이지의 액션이 기본으로 이동합니다. |
/newsletter를 사전 렌더링할 수 없어 빌드가 실패함 | 레이아웃이 prerender = true를 설정합니다. 액션이 있는 페이지에는 서버가 필요하므로 페이지의 서버 파일에 prerender = false를 설정하세요. |
| 푸터와 페이지 폼 모두 결과를 보여 줌 | 둘이 같은 page.form을 읽습니다. 샘플처럼 그 source를 컴포넌트의 것과 비교하세요. |
로컬 테스트에서 402 plan_required | POST에 localhost origin이 없었습니다. Origin 줄을 확인하고 localhost로 사이트를 여세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
질문
레이아웃에는 정의할 수 없습니다. 액션은 +page.server 파일에 속합니다. /newsletter 같은 페이지에 액션을 두고 레이아웃에서 action="/newsletter?/subscribe"로 POST하세요. 현재 페이지에 머물려면 update({ navigate: false })와 함께 use:enhance를 쓰세요.
네. 글은 사전 렌더링하고, 액션이 있는 페이지와 웹훅 라우트만 서버에 맡기세요. 서버가 전혀 없는 사이트라면 대신 브라우저에서 Ikiform으로 바로 POST할 수 있습니다.
누가 진짜인지는 웹훅만 알기 때문입니다. 폼 액션은 봇에게도 독자와 같은 성공 응답을 받지만, 웹훅은 Ikiform의 스팸 검사를 통과한 가입에만 발생하므로 Kit은 봇을 보지 않습니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.