뉴스레터 가입 폼

뉴스레터 가입 폼5분 분량

Nuxt로 뉴스레터 가입 폼 만들기

JavaScript 유무와 관계없이 동작하는 Nuxt 뉴스레터 폼입니다. Turnstile 확인을 거치는 서버 라우트가 Ikiform으로 POST하고, Nitro 라우트가 beehiiv로 넘깁니다.

Preet SutharIkiform 창업자

폼에 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을 응답합니다.

Nuxt 뉴스레터 라우트가 각 호출자에게 응답하는 방식NewsletterFormaction="/api/newsletter"newsletter.post.ts동의,Turnstile,IkiformAccept:JSON인가?요청한쪽JSON또는오류$fetch,같은페이지303/newsletter?result=...newsletter.vue메시지표시$fetch 또는 폼 POST결과예아니요Nuxt 뉴스레터 라우트가 각 호출자에게 응답하는 방식NewsletterFormaction="/api/newsletter"newsletter.post.ts동의, Turnstile, IkiformAccept:JSON인가?요청한 쪽JSON 또는 오류$fetch, 같은 페이지303/newsletter?result=...newsletter.vue메시지 표시$fetch 또는 폼 POST결과예아니요
  1. 폼이 /api/newsletter로 POST합니다. 하이드레이션 뒤에는 $fetch로, 그 전에는 일반 폼 POST로 보냅니다.
  2. 라우트가 동의와 Turnstile 토큰을 확인한 뒤 가입을 Ikiform으로 전달합니다.
  3. Accept 헤더를 읽어 응답 방식을 정합니다.
  4. $fetch가 JSON을 요청했다면 성공 시 JSON을 반환하고, 문제가 있으면 컴포넌트가 보여 줄 사유와 함께 오류를 throw합니다.
  5. 일반 폼 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
.env
nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@nuxtjs/turnstile"],
  turnstile: { siteKey: "your_turnstile_site_key" },
  runtimeConfig: {
    ikiformSecretKey: "",
    ikiformWebhookSecret: "",
    beehiivApiKey: "",
    beehiivPublicationId: "",
    turnstile: { secretKey: "" },
  },
  routeRules: {
    "/blog/**": { prerender: true },
  },
});
.env
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_key

runtimeConfig 아래의 모든 키는 런타임에 일치하는 NUXT_ 변수로 덮어쓰이므로, 설정에는 빈 문자열을 두고 값은 호스트에 둡니다. runtimeConfig.public 밖의 것은 브라우저에 닿지 않습니다. Turnstile 사이트 키는 원래 공개용입니다. API keys에서 submissions:write 스코프로 Ikiform 키를 만들고, 폼의 Fields 탭에 필러의 필드 정의를 붙여 넣으세요.

만들기

서버 라우트

server/api/newsletter.post.ts
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가 서버 라우트에서 권하는 방식입니다.
서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

폼 컴포넌트

app/components/NewsletterForm.vue
<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 페이지

app/pages/newsletter.vue
<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 자체의 레이트 리밋 헤더를 추가합니다.

server/routes/hooks/ikiform.post.ts
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는 여전히 서버 라우트에 도달합니다.

배포

호스트해결 방법
VercelNitro가 Vercel을 감지해 서버 라우트를 함수로 빌드합니다. Environment Variables에 NUXT_ 변수를 추가하세요.
NetlifyNitro가 같은 방식으로 Netlify를 감지합니다. 사이트의 환경 설정에 변수를 추가하세요.
Cloudflarenuxt generate가 아니라 nuxt build로 빌드하고, 프로젝트에 NUXT_ 변수를 시크릿으로 설정하세요. 웹훅 URL에는 커스텀 도메인을 쓰세요. Ikiform은 workers.dev와 pages.dev를 거부합니다.

localhost에서 무료로 테스트하기

플랜이 없는 워크스페이스는 localhost에서 월 50건의 테스트 제출을 받고, 전달되는 Origin 덕분에 nuxt dev의 각 가입이 그중 하나로 집계됩니다. 로컬에서는 확인이 항상 통과하도록 Cloudflare가 공개한 Turnstile 테스트 키를 쓰세요. Ikiform은 localhost로 웹훅을 전달하지 않습니다. /hooks/ikiform을 테스트하려면 HTTPS URL이 있는 터널로 개발 서버를 노출하세요.

작동 확인하기

  1. nuxt dev를 실행하고 푸터에서 구독하세요. 폼이 받은편지함을 확인하라는 문구로 바뀌고 URL은 바뀌지 않습니다.
  2. JavaScript를 끄고 구독하세요. 위젯이 실행될 수 없으므로 /newsletter?result=check로 이동합니다.
  3. JavaScript를 켠 상태에서 체크박스의 required를 제거하고 체크하지 않은 채 제출하세요. 동의 메시지가 나타나고 아무것도 저장되지 않습니다.
  4. 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
  5. 웹훅을 추가했다면 Webhooks에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께 succeeded로 표시됩니다.
  6. beehiiv를 여세요. 구독이 확인 전까지 대기 상태로 있고, 확인 이메일이 받은편지함에 있습니다.

주의할 점

문제해결 방법
프로덕션에서 /api/newsletter가 404임사이트를 서버가 없는 nuxt generate로 빌드했습니다. nuxt build로 빌드하고 routeRules로 페이지를 사전 렌더링하세요.
런타임에 시크릿 키가 비어 있음변수 이름이 설정 키와 맞지 않습니다. ikiformSecretKey에는 NUXT_IKIFORM_SECRET_KEY가 필요합니다.
사람 확인이 두 번에 한 번 실패함토큰을 재사용했습니다. 컴포넌트처럼 오류가 날 때마다 위젯에서 reset()을 호출하세요.
로컬 테스트에서 402 plan_requiredPOST에 localhost origin이 없었습니다. Origin 줄을 확인하고 localhost로 사이트를 여세요.
제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다.

질문

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