뉴스레터 가입 폼

뉴스레터 가입 폼6분 분량

SvelteKit으로 뉴스레터 가입 폼 만들기

레이아웃에 둔 SvelteKit 3 뉴스레터 폼입니다. 동의와 IP 한도가 있는 폼 액션, 페이지에 머무는 use:enhance, Kit 웹훅 라우트로 구성됩니다.

Preet SutharIkiform 창업자

폼 액션을 /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으로 적용되고 푸터가 이를 읽습니다.

SvelteKit 푸터 가입이 독자가 있는 페이지로 결과를 돌려주는 방식푸터폼+layout.svelte,모든페이지?/subscribe/newsletter의액션getClientAddress()10분에3회시도Ikiform시크릿키,가입저장page.form현재페이지에fail(429)메시지,전송안함use:enhance허용됨navigate: false제한 초과SvelteKit 푸터 가입이 독자가 있는 페이지로 결과를 돌려주는 방식푸터 폼+layout.svelte, 모든 페이지?/subscribe/newsletter의 액션getClientAddress()10분에 3회 시도Ikiform시크릿 키, 가입 저장page.form현재 페이지에fail(429)메시지, 전송 안 함use:enhance허용됨navigate: false제한 초과
  1. 독자가 푸터 폼을 제출합니다. use:enhance가 백그라운드에서 /newsletter의 subscribe 액션으로 보냅니다.
  2. 액션이 동의 체크박스를 확인한 뒤 독자 IP의 최근 시도를 셉니다.
  3. 한도를 넘으면 fail(429)을 반환하고 아무것도 보내지 않습니다.
  4. 그렇지 않으면 시크릿 키와 브라우저의 Origin과 함께 가입을 Ikiform으로 POST합니다.
  5. navigate false로 호출한 update가 결과를 현재 페이지에 page.form으로 적용하고 푸터가 이를 보여 줍니다. JavaScript가 없으면 브라우저가 같은 컴포넌트를 렌더링하는 /newsletter로 이동합니다.
source가 결과와 일치하는 폼만 반응하므로 푸터와 페이지 폼이 함께 바뀌지 않습니다.

프로젝트 설정

프로젝트 트리
src/
  env.ts
  lib/
    NewsletterForm.svelte
    server/limit.ts
  routes/
    +layout.svelte
    newsletter/
      +page.server.ts
      +page.svelte
    api/ikiform/+server.ts
.env

SvelteKit 3은 $env/* 모듈을 명시적 변수로 대체하며 지원 중단합니다. src/env.ts에 선언하세요. 기본으로 비공개이고 런타임에 읽히며, $app/env/private는 브라우저 코드에 import되는 것을 거부합니다.

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

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
  IKIFORM_WEBHOOK_SECRET: {},
  KIT_API_KEY: {},
  KIT_FORM_ID: {},
});
.env
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=1234567

API keys에서 submissions:write 스코프로 Ikiform 키를 만들고, 폼의 Fields 탭에 필러의 필드 정의를 붙여 넣으세요.

만들기

IP별 제한

사람은 한 번 구독합니다. 10분에 세 번이면 오타를 고칠 여유가 있고 스크립트는 막습니다. getClientAddress()는 어댑터가 판별한 IP를 반환하므로 리미터는 호스트가 어떤 헤더를 쓰는지 알 필요가 없습니다.

src/lib/server/limit.ts
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 별칭은 없어졌습니다.

액션

src/routes/newsletter/+page.server.ts
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가 중요합니다. 액션이 있는 페이지는 사전 렌더링할 수 없습니다.
서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

폼 컴포넌트

src/lib/NewsletterForm.svelte
<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 페이지

src/routes/newsletter/+page.svelte
<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의 각 응답을 설명하고 태그를 추가합니다.

src/routes/api/ikiform/+server.ts
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 토큰을 검증하세요.

배포

호스트해결 방법
Verceladapter-vercel은 액션을 함수로 실행합니다. SvelteKit 3은 그곳에서 edge 런타임을 없앴으므로 기본값을 쓰세요. Environment Variables에 변수를 설정하세요.
Netlifyadapter-netlify는 Netlify의 Frameworks API를 따르며, 이제 publish 디렉터리는 어댑터 옵션입니다. 사이트의 환경 설정에 변수를 설정하세요.
Cloudflareadapter-cloudflare는 Workers용으로 빌드하며 peer dependency인 Wrangler 4.118 이상이 필요합니다. 문서는 변수에 $app/env를 권합니다. 웹훅에는 커스텀 도메인을 쓰세요. Ikiform은 workers.dev와 pages.dev를 거부합니다.

localhost에서 무료로 테스트하기

플랜이 없는 워크스페이스는 localhost에서 월 50건의 테스트 제출을 받고, 전달되는 Origin 덕분에 개발 서버의 각 가입이 그중 하나로 집계됩니다. 리미터는 나도 세므로 테스트 중 한도에 걸리면 개발 서버를 재시작하세요. Ikiform은 localhost로 웹훅을 전달하지 않습니다. 라우트를 테스트하려면 HTTPS URL이 있는 터널로 개발 서버를 노출하세요.

작동 확인하기

  1. vite dev를 실행하고 아무 페이지나 열어 푸터에서 구독하세요. 푸터에 문구가 나타나고 URL은 바뀌지 않습니다.
  2. JavaScript를 끄고 다시 구독하세요. 같은 문구가 있는 /newsletter로 이동합니다.
  3. 10분 안에 네 번 제출하세요. 네 번째에 한도 메시지가 나오고 네트워크 탭에 429가 표시됩니다.
  4. 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
  5. 웹훅을 추가했다면 Webhooks에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께 succeeded로 표시됩니다.
  6. Kit를 여세요. 구독자가 inactive로 있고 Kit의 확인 이메일이 받은편지함에 있습니다.

주의할 점

문제해결 방법
JavaScript가 있는데도 푸터 폼을 제출하면 /newsletter로 이동함use:enhance 콜백이 navigate: false 없이 update()를 호출합니다. SvelteKit 3에서는 다른 페이지의 액션이 기본으로 이동합니다.
/newsletter를 사전 렌더링할 수 없어 빌드가 실패함레이아웃이 prerender = true를 설정합니다. 액션이 있는 페이지에는 서버가 필요하므로 페이지의 서버 파일에 prerender = false를 설정하세요.
푸터와 페이지 폼 모두 결과를 보여 줌둘이 같은 page.form을 읽습니다. 샘플처럼 그 source를 컴포넌트의 것과 비교하세요.
로컬 테스트에서 402 plan_requiredPOST에 localhost origin이 없었습니다. Origin 줄을 확인하고 localhost로 사이트를 여세요.
제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.