뉴스레터 가입 폼6분 분량
Next.js로 뉴스레터 가입 폼 만들기
Next.js 푸터 뉴스레터 폼입니다. 동의 확인과 Turnstile이 있는 서버 액션이 Ikiform으로 POST하고, 라우트 핸들러가 독자를 Kit에 추가합니다.
루트 레이아웃의 푸터에 클라이언트 폼을 두고 서버 액션을 가리키게 하세요. 액션이 동의 체크박스와 Cloudflare Turnstile 토큰을 확인한 뒤 시크릿 키로 가입을 폼 백엔드에 전달합니다. 별도의 라우트 핸들러가 백엔드의 서명된 웹훅을 받아 독자를 이메일 도구에 추가하므로, 스팸은 목록에 절대 닿지 않습니다.
메인 가이드의 Next.js 탭에는 서버 액션과 폼 컴포넌트가 있습니다. 이 페이지는 실제 사이트를 위한 전체 구성을 만듭니다. 모든 페이지 푸터에 있는 폼 하나, 시크릿 키가 Ikiform의 확인을 건너뛰어도 동작하는 사람 확인, 그리고 스팸이 아닌 가입을 Kit로 넘기는 라우트 핸들러입니다. 제품 설정과 키는 Next.js 문서 가이드에 있습니다.
Next.js에서 푸터 폼이 바꾸는 것
푸터 폼은 루트 레이아웃에 있으므로 Next.js가 배포 시 한 번 빌드하는 페이지를 포함해 모든 페이지에 렌더링됩니다. 여기서 세 가지가 따라 나옵니다.
| 고려 사항 | 동작 | 이 구성의 처리 방식 |
|---|---|---|
| 정적 페이지 | 폼이 렌더링 중에 쿠키나 헤더를 읽으면 모든 페이지가 동적이 됩니다 | 폼은 렌더링 시 아무것도 읽지 않습니다. 요청을 다루는 것은 액션뿐입니다 |
| 사람 확인 | 시크릿 키로 보낸 POST는 Ikiform의 Turnstile 확인을 건너뜁니다 | 액션 안에서 자체 Turnstile 확인을 실행합니다 |
| 호출할 수 있는 사람 | 서버 액션은 폼 없이도 접근할 수 있는 공개 POST 엔드포인트입니다 | 무엇이든 보내기 전에 동의와 토큰을 검증합니다 |
- 독자가 푸터 폼을 작성합니다. 읽고 있는 페이지가 어디든 그 페이지의 서버 액션으로 POST합니다.
- 액션이 동의 체크박스를 확인한 뒤 Cloudflare로 Turnstile 토큰을 검증합니다.
- 둘 중 하나라도 실패하면 폼이 메시지와 입력한 이메일과 함께 돌아옵니다. 아무것도 전송되지 않습니다.
- 그렇지 않으면 액션이 시크릿 키와 브라우저의 Origin과 함께 가입을 Ikiform으로 POST합니다.
- Ikiform의 응답이 액션 상태가 되고, 폼이 받은편지함을 확인하라는 문구로 바뀝니다.
- 이와 별도로 Ikiform은 스팸이 아닌 모든 가입에 서명된 웹훅을 보냅니다. 라우트 핸들러가 이를 검증하고 독자를 Kit 폼에 추가하며, 그 폼의 인센티브 이메일이 Kit의 확인 이메일입니다. 내 주소로 한 번 테스트하세요. 이유는 Kit 가이드에서 설명합니다.
프로젝트 설정
app/
layout.tsx
newsletter/
actions.ts
newsletter-form.tsx
submit-button.tsx
page.tsx
api/ikiform/route.ts
lib/
turnstile.ts
.env.localAPI keys에서 submissions:write 스코프로 시크릿 키를 만드세요. Cloudflare 대시보드에서 내 도메인과 localhost용 Turnstile 위젯을 만드세요. NEXT_PUBLIC_ 접두사를 붙일 수 있는 것은 사이트 키뿐이며, 나머지 값은 서버 전용으로 두어야 합니다.
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 탭에 필러의 필드 정의를 붙여 넣으면, 무엇이 도착하든 Ikiform이 필수 consent 체크박스를 강제합니다.
만들기
Turnstile 확인
Cloudflare의 siteverify 엔드포인트는 시크릿, 토큰, 그리고 선택적으로 방문자의 IP를 받습니다. 토큰은 300초 동안 유효하며 한 번만 확인할 수 있습니다.
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;
}서버 액션
"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은 값이 채워진 것을 스팸으로 저장하고 성공으로 응답하므로 봇은 아무것도 알 수 없습니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.폼과 처리 중 상태
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}"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가 위젯을 폼에 넣고, 폼은 hiddencf-turnstile-response입력을 POST합니다. 명시적 렌더링은 클라이언트 측 내비게이션 후 마운트되는 폼에서도 동작합니다. - 토큰은 한 번만 확인할 수 있으므로 오류가 날 때마다 위젯을 초기화합니다. 초기화하지 않으면 두 번째 시도는 항상 실패합니다.
레이아웃에 넣기
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 가이드에서 다룹니다.
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를 쓰세요.
작동 확인하기
next dev를 실행하고 푸터로 스크롤해 내 주소로 구독하세요. 받은편지함을 확인하라는 문구가 폼을 대체합니다.- Submissions를 여세요. 가입의
consent는true,source는footer입니다. - 개발자 도구에서 체크박스의
required를 제거하고 체크하지 않은 채 제출하세요. 동의 메시지가 나타나고 아무것도 저장되지 않습니다. - 개발자 도구에서
challenges.cloudflare.com을 차단하고 제출하세요. 사람 확인 메시지가 나타나고 아무것도 저장되지 않습니다. - 웹훅을 추가했다면 Webhooks에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께
succeeded로 표시됩니다. - Kit를 여세요. 구독자가 inactive로 있고 Kit의 확인 이메일이 받은편지함에 있습니다.
- 허니팟을 채워서
_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의 웹훅이 움직이게 하세요. 웹훅은 스팸이 아닌 가입에만 전송됩니다.
시크릿 키를 쓰면 건너뛰므로 안 됩니다. 이 페이지처럼 액션에서 직접 Turnstile을 실행하거나, @ikiform/sdk로 브라우저에서 POST하세요. 그러면 Ikiform의 확인, 허용 도메인, 방문자당 한도가 유지됩니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.