# Next.js로 뉴스레터 가입 폼 만들기

> Next.js 푸터 뉴스레터 폼입니다. 동의 확인과 Turnstile이 있는 서버 액션이 Ikiform으로 POST하고, 라우트 핸들러가 독자를 Kit에 추가합니다.

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

루트 레이아웃의 푸터에 클라이언트 폼을 두고 서버 액션을 가리키게 하세요. 액션이 동의 체크박스와 Cloudflare Turnstile 토큰을 확인한 뒤 시크릿 키로 가입을 폼 백엔드에 전달합니다. 별도의 라우트 핸들러가 백엔드의 서명된 웹훅을 받아 독자를 이메일 도구에 추가하므로, 스팸은 목록에 절대 닿지 않습니다.

[메인 가이드의 Next.js 탭](https://ikiform.com/ko/guides/newsletter-signup.md#form)에는 서버 액션과 폼 컴포넌트가 있습니다. 이 페이지는 실제 사이트를 위한 전체 구성을 만듭니다. 모든 페이지 푸터에 있는 폼 하나, 시크릿 키가 Ikiform의 확인을 건너뛰어도 동작하는 사람 확인, 그리고 스팸이 아닌 가입을 Kit로 넘기는 라우트 핸들러입니다. 제품 설정과 키는 [Next.js 문서 가이드](https://ikiform.com/ko/docs/guides/nextjs.md)에 있습니다.

## Next.js에서 푸터 폼이 바꾸는 것

푸터 폼은 루트 레이아웃에 있으므로 Next.js가 배포 시 한 번 빌드하는 페이지를 포함해 모든 페이지에 렌더링됩니다. 여기서 세 가지가 따라 나옵니다.

| 고려 사항 | 동작 | 이 구성의 처리 방식 |
| --- | --- | --- |
| 정적 페이지 | 폼이 렌더링 중에 쿠키나 헤더를 읽으면 모든 페이지가 동적이 됩니다 | 폼은 렌더링 시 아무것도 읽지 않습니다. 요청을 다루는 것은 액션뿐입니다 |
| 사람 확인 | 시크릿 키로 보낸 POST는 Ikiform의 Turnstile 확인을 건너뜁니다 | 액션 안에서 자체 Turnstile 확인을 실행합니다 |
| 호출할 수 있는 사람 | 서버 액션은 폼 없이도 접근할 수 있는 공개 POST 엔드포인트입니다 | 무엇이든 보내기 전에 동의와 토큰을 검증합니다 |

다이어그램: Next.js 푸터 가입이 Ikiform을 거쳐 Kit에 닿는 과정

1. 독자가 푸터 폼을 작성합니다. 읽고 있는 페이지가 어디든 그 페이지의 서버 액션으로 POST합니다.
2. 액션이 동의 체크박스를 확인한 뒤 Cloudflare로 Turnstile 토큰을 검증합니다.
3. 둘 중 하나라도 실패하면 폼이 메시지와 입력한 이메일과 함께 돌아옵니다. 아무것도 전송되지 않습니다.
4. 그렇지 않으면 액션이 시크릿 키와 브라우저의 Origin과 함께 가입을 Ikiform으로 POST합니다.
5. Ikiform의 응답이 액션 상태가 되고, 폼이 받은편지함을 확인하라는 문구로 바뀝니다.
6. 이와 별도로 Ikiform은 스팸이 아닌 모든 가입에 서명된 웹훅을 보냅니다. 라우트 핸들러가 이를 검증하고 독자를 Kit 폼에 추가하며, 그 폼의 인센티브 이메일이 Kit의 확인 이메일입니다. 내 주소로 한 번 테스트하세요. 이유는 [Kit 가이드](https://ikiform.com/ko/guides/newsletter-signup/kit.md)에서 설명합니다.

액션은 Kit를 직접 호출하지 않습니다. Ikiform은 스팸에도 실제 가입과 같은 성공으로 응답하므로, 어떤 가입이 실제인지는 웹훅만 압니다.

## 프로젝트 설정

`프로젝트 트리`:

```text
app/
  layout.tsx
  newsletter/
    actions.ts
    newsletter-form.tsx
    submit-button.tsx
    page.tsx
  api/ikiform/route.ts
lib/
  turnstile.ts
.env.local
```

**API keys**에서 `submissions:write` 스코프로 시크릿 키를 만드세요. Cloudflare 대시보드에서 내 도메인과 `localhost`용 Turnstile 위젯을 만드세요. `NEXT_PUBLIC_` 접두사를 붙일 수 있는 것은 사이트 키뿐이며, 나머지 값은 서버 전용으로 두어야 합니다.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567
```

폼의 **Fields** 탭에 [필러의 필드 정의](https://ikiform.com/ko/guides/newsletter-signup.md#fields)를 붙여 넣으면, 무엇이 도착하든 Ikiform이 필수 `consent` 체크박스를 강제합니다.

## 만들기

### Turnstile 확인

Cloudflare의 siteverify 엔드포인트는 시크릿, 토큰, 그리고 선택적으로 방문자의 IP를 받습니다. 토큰은 300초 동안 유효하며 한 번만 확인할 수 있습니다.

`lib/turnstile.ts`:

```ts
import "server-only";

const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";

export async function passesTurnstile(token: string, visitor?: string) {
  if (!token) return false;
  const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET_KEY ?? "", response: token });
  if (visitor) body.set("remoteip", visitor);
  const response = await fetch(SITEVERIFY, { method: "POST", body }).catch(() => null);
  const outcome = (await response?.json().catch(() => null)) as { success?: boolean } | null;
  return outcome?.success === true;
}
```

### 서버 액션

`app/newsletter/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { passesTurnstile } from "@/lib/turnstile";

export interface SignupState {
  status: "idle" | "subscribed" | "error";
  field?: "email" | "consent" | "check";
  message: string;
  email: string;
  firstName: string;
}

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
  const email = String(formData.get("email") ?? "").trim();
  const firstName = String(formData.get("first_name") ?? "").trim().slice(0, 80);
  const kept = { email, firstName };

  if (formData.get("consent") !== "true") {
    return { ...kept, status: "error", field: "consent", message: "Tick the box so we know you want the emails." };
  }

  const request = await headers();
  const visitor = request.get("x-forwarded-for")?.split(",")[0]?.trim();
  if (!(await passesTurnstile(String(formData.get("cf-turnstile-response") ?? ""), visitor))) {
    return { ...kept, status: "error", field: "check", message: "The human check didn't pass. Try once more." };
  }

  const source = String(formData.get("source") ?? "");
  let response: Response;
  try {
    response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        Origin: request.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        first_name: firstName || undefined,
        consent: true,
        source: PLACEMENTS.includes(source) ? source : "footer",
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { ...kept, status: "error", message: "We couldn't reach the list. Try again in a moment." };
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) {
    return { status: "subscribed", message: "Almost done. Open the email we sent you and confirm your address.", email: "", firstName: "" };
  }
  const emailError = result?.error?.fields?.email?.[0];
  if (emailError) return { ...kept, status: "error", field: "email", message: emailError };
  return { ...kept, status: "error", message: result?.error?.message ?? "Couldn't subscribe. Try again." };
}
```

- 동의는 비용이 들지 않으므로 먼저 확인합니다. Turnstile 호출은 그다음입니다.
- `source`는 고정 목록과 대조합니다. 조작된 POST가 위치 리포트를 쓰레기로 채울 수 없습니다.
- `Origin` 줄은 브라우저의 origin을 전달합니다. Ikiform은 이 헤더로 무료 `localhost` 테스트를 알아보는데, 서버의 `fetch`는 이를 보내지 않기 때문입니다.
- 모든 오류는 입력한 이메일과 이름을 반환합니다. React는 액션 후 폼을 초기화하고, `defaultValue`가 값을 되돌려 놓습니다.
- 허니팟은 그대로 전달됩니다. Ikiform은 값이 채워진 것을 스팸으로 저장하고 성공으로 응답하므로 봇은 아무것도 알 수 없습니다.

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

### 폼과 처리 중 상태

`app/newsletter/submit-button.tsx`:

```tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}
```

`app/newsletter/newsletter-form.tsx`:

```tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useId, useRef } from "react";
import { type SignupState, subscribe } from "./actions";
import { SubmitButton } from "./submit-button";

declare global {
  interface Window {
    turnstile?: { render(box: HTMLElement, options: { sitekey: string }): string; reset(id: string): void };
  }
}

const initial: SignupState = { status: "idle", message: "", email: "", firstName: "" };

export function NewsletterForm({ source }: { source: "footer" | "post" | "newsletter-page" }) {
  const [state, action] = useActionState(subscribe, initial);
  const id = useId();
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mountCheck() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "",
    });
  }

  useEffect(mountCheck, []);
  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  if (state.status === "subscribed") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" onLoad={mountCheck} />
      <label htmlFor={`${id}-email`}>Email</label>
      <input
        id={`${id}-email`}
        name="email"
        type="email"
        required
        autoComplete="email"
        defaultValue={state.email}
        aria-invalid={state.field === "email"}
      />
      <label htmlFor={`${id}-name`}>First name (optional)</label>
      <input id={`${id}-name`} name="first_name" maxLength={80} autoComplete="given-name" defaultValue={state.firstName} />
      <label>
        <input name="consent" type="checkbox" value="true" required aria-invalid={state.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 />
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <SubmitButton />
    </form>
  );
}
```

- `useFormStatus`는 `<form>`의 자식에서 읽어야 하므로 버튼을 별도 컴포넌트로 둡니다.
- `useId`는 푸터 폼과 `/newsletter` 페이지 폼이 함께 화면에 있을 때 입력 ID가 겹치지 않게 합니다.
- Turnstile 스크립트는 `?render=explicit`로 로드됩니다. `render`가 위젯을 폼에 넣고, 폼은 hidden `cf-turnstile-response` 입력을 POST합니다. 명시적 렌더링은 클라이언트 측 내비게이션 후 마운트되는 폼에서도 동작합니다.
- 토큰은 한 번만 확인할 수 있으므로 오류가 날 때마다 위젯을 초기화합니다. 초기화하지 않으면 두 번째 시도는 항상 실패합니다.

### 레이아웃에 넣기

`app/layout.tsx`:

```tsx
import { NewsletterForm } from "./newsletter/newsletter-form";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        {children}
        <footer>
          <p>One email a month about what we shipped. No tracking pixels.</p>
          <NewsletterForm source="footer" />
        </footer>
      </body>
    </html>
  );
}
```

글 끝에는 `source="post"`를, 전용 페이지에는 `source="newsletter-page"`를 쓰세요. `LayoutProps`는 Next.js가 레이아웃용으로 생성하는 라우트 인식 타입입니다.

## 구독자를 Kit로 전달하기

아래 라우트 핸들러가 Ikiform의 웹훅을 받습니다. 서명은 정확한 바이트를 대상으로 하므로 본문을 먼저 텍스트로 읽습니다. **Webhooks**에 URL을 추가하고, 뉴스레터 폼을 고르고, `whsec_` 시크릿을 복사하세요. 태그와 Kit 응답별 의미는 [Kit 가이드](https://ikiform.com/ko/guides/newsletter-signup/kit.md)에서 다룹니다.

`app/api/ikiform/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

function kit(path: string, body: Record<string, unknown>) {
  return fetch(`https://api.kit.com/v4${path}`, {
    method: "POST",
    headers: {
      "X-Kit-Api-Key": process.env.KIT_API_KEY ?? "",
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(body),
  });
}

export async function POST(request: Request) {
  let event;
  try {
    event = await verifyWebhook({
      payload: await request.text(),
      headers: request.headers,
      secret: process.env.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 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/${process.env.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 });
}
```

- 세션 없는 방문자를 리디렉션하는 `proxy.ts`(이전의 `middleware.ts`)가 있다면 `/api/ikiform`을 `matcher`에서 제외하세요. 그러지 않으면 모든 전달이 핸들러 대신 로그인 리디렉션을 받습니다.
- `next.config`에 `trailingSlash: true`가 있으면 Next.js가 `/api/ikiform`을 `/api/ikiform/`으로 리디렉션합니다. 슬래시를 붙인 URL을 등록하세요.
- `Retry-After`가 붙은 `503`은 Ikiform이 기다렸다가 다시 시도하게 하고, `502`는 Kit의 상태를 전달 로그에 남깁니다.

## 렌더링과 점진적 향상

폼의 어떤 것도 렌더링 중에 요청을 읽지 않으므로 레이아웃이 정적 페이지를 동적으로 만들지 않습니다. 액션은 페이지의 렌더링 모드와 관계없이 요청마다 실행됩니다. 액션이 바로 `action`에 들어가므로 React가 실제 폼 POST를 렌더링하고, JavaScript가 로드되기 전에 제출한 독자도 액션에 도달합니다. 다만 Turnstile 위젯에는 JavaScript가 필요하므로, 스크립트가 꺼진 독자는 가입 대신 사람 확인 메시지를 받습니다. 독자에게 이 절충이 맞지 않다면 확인을 빼고 허니팟과 필수 체크박스에 의존하세요.

## 배포

| 호스트 | 해결 방법 |
| --- | --- |
| Vercel | 프로젝트의 **Environment Variables**에 변수 여섯 개를 추가하세요. Ikiform 웹훅은 프로덕션 도메인으로 보내세요. 미리보기 배포는 Vercel의 Deployment Protection 뒤에 있을 수 있고, 그러면 웹훅이 거부됩니다. |
| Netlify | Netlify는 자체 어댑터로 Next.js를 빌드합니다. 사이트의 환경 설정에 Functions 스코프로 변수를 추가하고 다시 배포하세요. |
| Cloudflare | OpenNext로 Next.js를 Workers에서 실행하세요. Worker 설정에 변수를 넣고 Worker에 커스텀 도메인을 주세요. Ikiform은 `workers.dev` 웹훅 URL을 거부합니다. |

## localhost에서 무료로 테스트하기

플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받습니다. 전달된 `Origin` 덕분에 `http://localhost:3000`의 `next dev`가 테스트로 집계됩니다. 내 컴퓨터에서 확인이 항상 통과하도록 `.env.local`에 Cloudflare가 공개한 Turnstile 테스트 키를 쓰세요. 웹훅은 `localhost`에 닿을 수 없습니다. 라우트 핸들러를 시험하려면 HTTPS URL을 주는 터널로 노출하거나, 배포한 뒤 대시보드에서 **Send test**를 쓰세요.

## 작동 확인하기

1. `next dev`를 실행하고 푸터로 스크롤해 내 주소로 구독하세요. 받은편지함을 확인하라는 문구가 폼을 대체합니다.
2. **Submissions**를 여세요. 가입의 `consent`는 `true`, `source`는 `footer`입니다.
3. 개발자 도구에서 체크박스의 `required`를 제거하고 체크하지 않은 채 제출하세요. 동의 메시지가 나타나고 아무것도 저장되지 않습니다.
4. 개발자 도구에서 `challenges.cloudflare.com`을 차단하고 제출하세요. 사람 확인 메시지가 나타나고 아무것도 저장되지 않습니다.
5. 웹훅을 추가했다면 **Webhooks**에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께 `succeeded`로 표시됩니다.
6. Kit를 여세요. 구독자가 inactive로 있고 Kit의 확인 이메일이 받은편지함에 있습니다.
7. 허니팟을 채워서 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 모든 로컬 테스트에서 `402 plan_required` | POST가 `localhost` origin 없이 Ikiform에 도달했습니다. 액션의 `Origin` 줄을 확인하고, 네트워크 주소가 아니라 `localhost`로 앱을 여세요. |
| 사람 확인이 매번 실패함 | 사이트 키와 시크릿이 서로 다른 위젯에서 왔거나, 위젯이 이 호스트 이름용으로 설정되지 않았습니다. 테스트 키는 테스트 시크릿과 함께일 때만 통과합니다. |
| 푸터를 추가한 뒤 모든 정적 페이지가 동적이 됨 | 폼이나 레이아웃의 무언가가 렌더링 중에 `headers()`나 `cookies()`를 호출합니다. 요청은 액션 안에서만 읽으세요. |
| 배포 직후 Failed to find Server Action | 어떤 탭이 아직 액션 ID가 바뀐 이전 빌드를 실행 중입니다. 독자가 새로고침하면 동작합니다. |
| 웹훅 전달에 `307`이나 `308`이 표시됨 | proxy 리디렉션이나 trailing slash 설정이 `/api/ikiform`을 잡았습니다. matcher에서 제외하거나 슬래시를 붙인 URL을 등록하세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |

## 질문

### 서버 액션이 이메일 도구에 구독자를 직접 추가해야 하나요?

아니요. Ikiform은 스팸에도 실제 가입과 같은 성공으로 응답하므로 액션은 둘을 구분할 수 없습니다. 이메일 도구는 Ikiform의 웹훅이 움직이게 하세요. 웹훅은 스팸이 아닌 가입에만 전송됩니다.

### Next.js 서버 액션에서 Ikiform 자체의 사람 확인을 쓸 수 있나요?

시크릿 키를 쓰면 건너뛰므로 안 됩니다. 이 페이지처럼 액션에서 직접 Turnstile을 실행하거나, @ikiform/sdk로 브라우저에서 POST하세요. 그러면 Ikiform의 확인, 허용 도메인, 방문자당 한도가 유지됩니다.

### 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은 확인 이메일을 보내지 않으므로 더블 옵트인은 이메일 도구에서 실행됩니다. 릴레이가 각 가입을 미확인으로 추가하고 도구가 링크를 보냅니다.
- [Astro 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/astro.md): 사전 렌더링 페이지에 그대로 두는 Astro 뉴스레터 폼입니다. Astro Action이 동의를 확인해 Ikiform으로 POST하고, 엔드포인트가 독자를 MailerLite에 추가합니다.
- [정적 사이트](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에 연결합니다. 릴레이가 새 독자를 더블 옵트인을 위해 미확인으로 추가하고, 구독자에게 두 번 확인을 요청하지 않습니다.
