뉴스레터 가입 폼5분 분량
Nuxt로 뉴스레터 가입 폼 만들기
JavaScript 유무와 관계없이 동작하는 Nuxt 뉴스레터 폼입니다. Turnstile 확인을 거치는 서버 라우트가 Ikiform으로 POST하고, Nitro 라우트가 beehiiv로 넘깁니다.
폼에 Nuxt 서버 라우트를 가리키는 실제 action을 주고, $fetch로 향상해 JavaScript가 있는 독자는 페이지에 머물게 하세요. 라우트는 동의 체크박스와 @nuxtjs/turnstile의 Turnstile 토큰을 확인하고, 시크릿 키로 가입을 폼 백엔드로 전달하며, 요청한 쪽에 따라 JSON이나 리디렉션으로 응답합니다. 두 번째 라우트가 백엔드의 서명된 웹훅을 beehiiv 구독으로 바꿉니다.
메인 가이드의 Nuxt 탭은 $fetch로 POST하며 Vue가 하이드레이션된 뒤에 동작합니다. 이 페이지는 같은 폼을 그 전에도 동작하게 하고, 사람 확인을 더하고, 실제 가입을 beehiiv로 보냅니다. 제품 설정은 Nuxt 문서 가이드에 있습니다.
라우트 하나, 두 종류의 호출자
Nuxt 서버 라우트는 브라우저 폼과 $fetch 중 누가 호출했는지 모르므로 Accept 헤더로 판단합니다. 컴포넌트의 $fetch는 JSON을 요청하고 JSON이나 사유가 담긴 오류를 받습니다. 하이드레이션 전이나 스크립트가 꺼진 일반 폼 POST는 HTML을 요청하므로, 라우트가 결과 코드와 함께 /newsletter로 303을 응답합니다.
- 폼이 /api/newsletter로 POST합니다. 하이드레이션 뒤에는 $fetch로, 그 전에는 일반 폼 POST로 보냅니다.
- 라우트가 동의와 Turnstile 토큰을 확인한 뒤 가입을 Ikiform으로 전달합니다.
- Accept 헤더를 읽어 응답 방식을 정합니다.
- $fetch가 JSON을 요청했다면 성공 시 JSON을 반환하고, 문제가 있으면 컴포넌트가 보여 줄 사유와 함께 오류를 throw합니다.
- 일반 폼 POST는 결과 코드와 함께 /newsletter로 303 리디렉션을 받고, 페이지가 코드를 메시지로 바꿉니다.
프로젝트 설정
npx nuxi@latest module add turnstile로 Turnstile 모듈을 설치한 뒤:
app/
components/NewsletterForm.vue
pages/newsletter.vue
server/
api/newsletter.post.ts
routes/hooks/ikiform.post.ts
nuxt.config.ts
.envexport default defineNuxtConfig({
modules: ["@nuxtjs/turnstile"],
turnstile: { siteKey: "your_turnstile_site_key" },
runtimeConfig: {
ikiformSecretKey: "",
ikiformWebhookSecret: "",
beehiivApiKey: "",
beehiivPublicationId: "",
turnstile: { secretKey: "" },
},
routeRules: {
"/blog/**": { prerender: true },
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
NUXT_BEEHIIV_API_KEY=your_beehiiv_api_key
NUXT_BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000
NUXT_TURNSTILE_SECRET_KEY=your_turnstile_secret_keyruntimeConfig 아래의 모든 키는 런타임에 일치하는 NUXT_ 변수로 덮어쓰이므로, 설정에는 빈 문자열을 두고 값은 호스트에 둡니다. runtimeConfig.public 밖의 것은 브라우저에 닿지 않습니다. Turnstile 사이트 키는 원래 공개용입니다. API keys에서 submissions:write 스코프로 Ikiform 키를 만들고, 폼의 Fields 탭에 필러의 필드 정의를 붙여 넣으세요.
만들기
서버 라우트
interface Signup {
email?: string;
first_name?: string;
consent?: boolean | string;
source?: string;
_gotcha?: string;
"cf-turnstile-response"?: string;
}
type Result = "subscribed" | "consent" | "check" | "email" | "failed";
const PLACEMENTS = ["footer", "post", "newsletter-page"];
export default defineEventHandler(async (event) => {
const body = (await readBody<Signup>(event)) ?? {};
const wantsJson = getRequestHeader(event, "accept")?.includes("application/json") ?? false;
const config = useRuntimeConfig(event);
const finish = (result: Result, status = 200, message = "") => {
if (!wantsJson) return sendRedirect(event, `/newsletter?result=${result}`, 303);
if (result === "subscribed") return { subscribed: true };
throw createError({ status, message, data: { reason: result } });
};
if (body.consent !== true && body.consent !== "true") {
return finish("consent", 422, "Tick the box so we know you want the emails.");
}
const check = await verifyTurnstileToken(String(body["cf-turnstile-response"] ?? ""), event);
if (!check.success) return finish("check", 403, "The human check didn't pass. Try once more.");
const source = PLACEMENTS.includes(String(body.source)) ? String(body.source) : "footer";
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: String(body.email ?? "").trim(),
first_name: String(body.first_name ?? "").trim().slice(0, 80) || undefined,
consent: true,
source,
_gotcha: String(body._gotcha ?? ""),
}),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return finish("subscribed");
const emailError = result?.error?.fields?.email?.[0];
if (emailError) return finish("email", 422, emailError);
return finish("failed", response?.status ?? 502, result?.error?.message ?? "We couldn't reach the list. Try again.");
});readBody는$fetch의 JSON과 일반 폼 POST의 URL 인코딩 본문을 모두 파싱합니다. 그래서 동의를true또는"true"로 받습니다.verifyTurnstileToken은 Turnstile 모듈에서 오며runtimeConfig.turnstile.secretKey를 읽습니다. 토큰은 일회용이며 5분 뒤 만료됩니다.Origin줄은 브라우저의 origin을 전달합니다. Ikiform은 이 헤더로 무료localhost테스트를 알아보는데, 서버의fetch는 이를 보내지 않기 때문입니다.useRuntimeConfig(event)는 런타임NUXT_덮어쓰기가 적용된 값을 가져오며, Nuxt가 서버 라우트에서 권하는 방식입니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.폼 컴포넌트
<script setup lang="ts">
const props = withDefaults(defineProps<{ source?: "footer" | "post" | "newsletter-page" }>(), { source: "footer" });
const turnstile = ref<{ reset: () => void }>();
const token = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const problem = ref<{ reason: string; message: string } | null>(null);
async function subscribe(event: Event) {
const fields = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
status.value = "sending";
problem.value = null;
try {
await $fetch("/api/newsletter", {
method: "POST",
headers: { Accept: "application/json" },
body: { ...fields, consent: fields.consent === "true", "cf-turnstile-response": token.value },
});
status.value = "done";
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { reason?: string } } }).data;
problem.value = { reason: data?.data?.reason ?? "failed", message: data?.message ?? "Couldn't subscribe. Try again." };
turnstile.value?.reset();
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
<form v-else action="/api/newsletter" method="post" @submit.prevent="subscribe">
<label>
Email
<input name="email" type="email" required autocomplete="email" :aria-invalid="problem?.reason === '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="problem?.reason === 'consent'" />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" :value="props.source" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<NuxtTurnstile ref="turnstile" v-model="token" />
<p v-if="problem" role="alert">{{ problem.message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
</form>
</template>- 폼은
action과method를 유지하므로 하이드레이션 뒤@submit.prevent가 넘겨받기 전까지 일반 방식으로 POST합니다. <NuxtTurnstile>은 위젯을 렌더링하고 토큰을token에 넣습니다. 오류가 나면 토큰이 소모되므로reset()으로 새 토큰을 요청합니다.- 필드는
event.currentTarget이 아직 폼을 가리키는 첫await전에FormData에서 읽습니다.
/newsletter 페이지
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
subscribed: "Almost done. Open the email we sent you and confirm your address.",
consent: "Tick the box so we know you want the emails.",
check: "The human check needs JavaScript. Turn it on and try again.",
email: "Check the email address and try again.",
failed: "We couldn't reach the list. Try again.",
};
const route = useRoute();
const message = computed(() => MESSAGES[String(route.query.result)] ?? "");
</script>
<template>
<main>
<h1>The monthly newsletter</h1>
<p v-if="message" :role="route.query.result === 'subscribed' ? 'status' : 'alert'">{{ message }}</p>
<NewsletterForm v-if="route.query.result !== 'subscribed'" source="newsletter-page" />
</main>
</template>Turnstile에는 JavaScript가 필요하므로 스크립트를 끈 독자는 check 메시지를 받습니다. 독자에게 맞지 않는 절충이라면 Turnstile 줄을 제거하고 허니팟과 필수 체크박스에 의존하세요.
구독자를 beehiiv로 전달하기
이 라우트는 server/routes에 있으므로 /api 접두사가 없습니다. 서명은 정확한 바이트를 대상으로 하므로 무엇이든 파싱하기 전에 readRawBody로 본문을 읽고, double_opt_override: "on"으로 구독을 만들어 beehiiv가 확인 이메일을 보내게 합니다. beehiiv 가이드는 이름과 beehiiv 자체의 레이트 리밋 헤더를 추가합니다.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
let delivery;
try {
delivery = await verifyWebhook({
payload: (await readRawBody(event)) ?? "",
headers: event.headers,
secret: config.ikiformWebhookSecret,
});
} catch (error) {
if (error instanceof WebhookVerificationError) {
setResponseStatus(event, 400);
return error.reason;
}
throw error;
}
if (delivery.type !== "submission.created") return sendNoContent(event);
const { email, consent, source } = delivery.data.fields;
if (consent !== true || typeof email !== "string") return "Skipped";
const answer = await fetch(
`https://api.beehiiv.com/v2/publications/${config.beehiivPublicationId}/subscriptions`,
{
method: "POST",
headers: { Authorization: `Bearer ${config.beehiivApiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
email,
double_opt_override: "on",
reactivate_existing: false,
utm_source: typeof source === "string" ? source : "website",
referring_site: delivery.data.meta.referrer ?? undefined,
}),
},
);
if (answer.status === 429) {
setResponseHeader(event, "retry-after", 60);
setResponseStatus(event, 503);
return "beehiiv rate limit";
}
if (!answer.ok) {
setResponseStatus(event, 502);
return `beehiiv answered ${answer.status}`;
}
return sendNoContent(event);
});Webhooks에 https://your-site.com/hooks/ikiform를 추가하고 뉴스레터 폼을 고른 뒤 whsec_ 시크릿을 NUXT_IKIFORM_WEBHOOK_SECRET에 복사하세요.
허니팟과 사람 확인
_gotcha 입력은 전달되고, Ikiform은 값이 채워진 것을 성공 응답과 함께 웹훅 없이 스팸으로 저장하므로 beehiiv에 닿지 않습니다. 시크릿 키는 Ikiform 자체의 Turnstile 확인을 건너뛰므로, 라우트가 직접 둔 위젯으로 확인을 실행합니다.
렌더링: generate가 아니라 하이브리드
nuxt generate는 서버 없는 정적 사이트를 만들므로 그 뒤에는 /api/newsletter와 /hooks/ikiform이 존재하지 않습니다. nuxt build로 빌드하고 정적일 수 있는 페이지는 위 설정이 /blog/**에 하듯 routeRules로 사전 렌더링하세요. 사전 렌더링된 페이지도 폼을 유지하며, POST는 여전히 서버 라우트에 도달합니다.
배포
| 호스트 | 해결 방법 |
|---|---|
| Vercel | Nitro가 Vercel을 감지해 서버 라우트를 함수로 빌드합니다. Environment Variables에 NUXT_ 변수를 추가하세요. |
| Netlify | Nitro가 같은 방식으로 Netlify를 감지합니다. 사이트의 환경 설정에 변수를 추가하세요. |
| Cloudflare | nuxt generate가 아니라 nuxt build로 빌드하고, 프로젝트에 NUXT_ 변수를 시크릿으로 설정하세요. 웹훅 URL에는 커스텀 도메인을 쓰세요. Ikiform은 workers.dev와 pages.dev를 거부합니다. |
localhost에서 무료로 테스트하기
플랜이 없는 워크스페이스는 localhost에서 월 50건의 테스트 제출을 받고, 전달되는 Origin 덕분에 nuxt dev의 각 가입이 그중 하나로 집계됩니다. 로컬에서는 확인이 항상 통과하도록 Cloudflare가 공개한 Turnstile 테스트 키를 쓰세요. Ikiform은 localhost로 웹훅을 전달하지 않습니다. /hooks/ikiform을 테스트하려면 HTTPS URL이 있는 터널로 개발 서버를 노출하세요.
작동 확인하기
nuxt dev를 실행하고 푸터에서 구독하세요. 폼이 받은편지함을 확인하라는 문구로 바뀌고 URL은 바뀌지 않습니다.- JavaScript를 끄고 구독하세요. 위젯이 실행될 수 없으므로
/newsletter?result=check로 이동합니다. - JavaScript를 켠 상태에서 체크박스의
required를 제거하고 체크하지 않은 채 제출하세요. 동의 메시지가 나타나고 아무것도 저장되지 않습니다. - 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
- 웹훅을 추가했다면 Webhooks에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께
succeeded로 표시됩니다. - beehiiv를 여세요. 구독이 확인 전까지 대기 상태로 있고, 확인 이메일이 받은편지함에 있습니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
프로덕션에서 /api/newsletter가 404임 | 사이트를 서버가 없는 nuxt generate로 빌드했습니다. nuxt build로 빌드하고 routeRules로 페이지를 사전 렌더링하세요. |
| 런타임에 시크릿 키가 비어 있음 | 변수 이름이 설정 키와 맞지 않습니다. ikiformSecretKey에는 NUXT_IKIFORM_SECRET_KEY가 필요합니다. |
| 사람 확인이 두 번에 한 번 실패함 | 토큰을 재사용했습니다. 컴포넌트처럼 오류가 날 때마다 위젯에서 reset()을 호출하세요. |
로컬 테스트에서 402 plan_required | POST에 localhost origin이 없었습니다. Origin 줄을 확인하고 localhost로 사이트를 여세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
질문
네, 실제 action과 method를 유지한다면 됩니다. 이 페이지처럼 일반 폼 POST에는 리디렉션하고 $fetch에는 JSON을 반환하는 서버 라우트를 가리키게 하세요.
아니요. nuxt generate는 정적 파일만 출력하므로 서버 라우트가 없습니다. routeRules와 함께 nuxt build로 정적 페이지를 사전 렌더링하거나, 브라우저에서 Ikiform으로 폼을 바로 POST하세요.
서버 라우트는 Ikiform의 응답만 보는데, 스팸도 실제 가입과 같은 성공을 받습니다. beehiiv는 스팸 검사 뒤에야 독자를 알아야 하며, 웹훅이 발생하는 때가 바로 그때입니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.