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

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

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

폼 액션을 `/newsletter` 페이지에 두고 루트 레이아웃의 폼에서 그곳으로 POST하세요. `update({ navigate: false })`와 함께 쓰는 `use:enhance`는 독자가 있는 페이지에 결과를 적용하므로 푸터가 글을 떠나지 않고 결과를 보여 줍니다. JavaScript가 없으면 같은 POST가 `/newsletter`로 이동합니다. 액션은 동의를 확인하고 `getClientAddress()`로 IP마다 한도를 두고 가입을 폼 백엔드로 전달하며, `+server.ts` 라우트가 검증된 가입을 Kit으로 넘깁니다.

[메인 가이드의 SvelteKit 탭](https://ikiform.com/ko/guides/newsletter-signup.md#form)은 푸터에서 `/newsletter` 액션으로 POST하고 결과를 직접 처리합니다. 이 페이지는 SvelteKit 3 방식으로 만듭니다. 명시적 환경 변수, 프레임워크가 결과를 적용하는 `update({ navigate: false })`, IP별 한도, Kit용 웹훅 라우트입니다. 제품 설정은 [SvelteKit 문서 가이드](https://ikiform.com/ko/docs/guides/sveltekit.md)에 있습니다.

## SvelteKit에서 레이아웃 폼이 바꾸는 것

폼 액션은 `+page.server.ts`에만 있고 레이아웃에는 둘 수 없으므로, 푸터 폼은 다른 페이지의 액션으로 POST합니다. SvelteKit 3은 여기서 향상된 폼이 네이티브 폼처럼 동작하게 했습니다. 액션이 다른 페이지에 있는 폼은 그 페이지로 이동합니다. 문서가 이를 끄는 경우로 꼽는 것이 바로 뉴스레터 위젯이며, `update({ navigate: false })`로 끕니다. 그러면 결과가 현재 페이지에 `page.form`으로 적용되고 푸터가 이를 읽습니다.

다이어그램: SvelteKit 푸터 가입이 독자가 있는 페이지로 결과를 돌려주는 방식

1. 독자가 푸터 폼을 제출합니다. use:enhance가 백그라운드에서 /newsletter의 subscribe 액션으로 보냅니다.
2. 액션이 동의 체크박스를 확인한 뒤 독자 IP의 최근 시도를 셉니다.
3. 한도를 넘으면 fail(429)을 반환하고 아무것도 보내지 않습니다.
4. 그렇지 않으면 시크릿 키와 브라우저의 Origin과 함께 가입을 Ikiform으로 POST합니다.
5. navigate false로 호출한 update가 결과를 현재 페이지에 page.form으로 적용하고 푸터가 이를 보여 줍니다. JavaScript가 없으면 브라우저가 같은 컴포넌트를 렌더링하는 /newsletter로 이동합니다.

source가 결과와 일치하는 폼만 반응하므로 푸터와 페이지 폼이 함께 바뀌지 않습니다.

## 프로젝트 설정

`프로젝트 트리`:

```text
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`:

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

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

`.env`:

```bash
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** 탭에 [필러의 필드 정의](https://ikiform.com/ko/guides/newsletter-signup.md#fields)를 붙여 넣으세요.

## 만들기

### IP별 제한

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

`src/lib/server/limit.ts`:

```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`:

```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 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

### 폼 컴포넌트

`src/lib/NewsletterForm.svelte`:

```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`:

```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 가이드](https://ikiform.com/ko/guides/newsletter-signup/kit.md)는 Kit의 각 응답을 설명하고 태그를 추가합니다.

`src/routes/api/ikiform/+server.ts`:

```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 가이드](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)처럼 액션에서 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이 있는 터널로 개발 서버를 노출하세요.

## 작동 확인하기

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_required` | POST에 `localhost` origin이 없었습니다. `Origin` 줄을 확인하고 `localhost`로 사이트를 여세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |

## 질문

### SvelteKit 폼 액션을 레이아웃에 두려면 어떻게 하나요?

레이아웃에는 정의할 수 없습니다. 액션은 +page.server 파일에 속합니다. /newsletter 같은 페이지에 액션을 두고 레이아웃에서 action="/newsletter?/subscribe"로 POST하세요. 현재 페이지에 머물려면 update({ navigate: false })와 함께 use:enhance를 쓰세요.

### 사전 렌더링한 SvelteKit 사이트에 뉴스레터 폼을 둘 수 있나요?

네. 글은 사전 렌더링하고, 액션이 있는 페이지와 웹훅 라우트만 서버에 맡기세요. 서버가 전혀 없는 사이트라면 대신 브라우저에서 Ikiform으로 바로 POST할 수 있습니다.

### 구독자가 웹훅을 거쳐 Kit에 도달하는 이유는 무엇인가요?

누가 진짜인지는 웹훅만 알기 때문입니다. 폼 액션은 봇에게도 독자와 같은 성공 응답을 받지만, 웹훅은 Ikiform의 스팸 검사를 통과한 가입에만 발생하므로 Kit은 봇을 보지 않습니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [뉴스레터 가입 폼](https://ikiform.com/ko/guides/newsletter-signup.md): 직접 작성한 마크업으로 뉴스레터 가입 폼을 만들고, 동의를 기록하고, 봇 가입을 막고, 각 구독자를 Kit, MailerLite, beehiiv 등 어떤 도구로든 넘깁니다.
- [Kit (구 ConvertKit)](https://ikiform.com/ko/guides/newsletter-signup/kit.md): 구독자를 만들고, 인센티브 이메일을 위해 Kit 폼에 추가하고, 가입 위치를 태그하는 릴레이로 내 뉴스레터 폼을 Kit에 연결합니다.
- [더블 옵트인](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md): Ikiform은 확인 이메일을 보내지 않으므로 더블 옵트인은 이메일 도구에서 실행됩니다. 릴레이가 각 가입을 미확인으로 추가하고 도구가 링크를 보냅니다.
- [Nuxt 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/nuxt.md): JavaScript 유무와 관계없이 동작하는 Nuxt 뉴스레터 폼입니다. Turnstile 확인을 거치는 서버 라우트가 Ikiform으로 POST하고, Nitro 라우트가 beehiiv로 넘깁니다.
- [정적 사이트](https://ikiform.com/ko/guides/newsletter-signup/static-site.md): Hugo, Jekyll, Eleventy, Astro 정적 사이트에 뉴스레터 가입 폼을 추가하고, 이메일 도구의 API 키는 페이지에서 빼고, 확인은 도구에서 합니다.
- [MailerLite](https://ikiform.com/ko/guides/newsletter-signup/mailerlite.md): 내 가입 폼을 MailerLite에 연결합니다. 릴레이가 새 독자를 더블 옵트인을 위해 미확인으로 추가하고, 구독자에게 두 번 확인을 요청하지 않습니다.
