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

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

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

폼에 Nuxt 서버 라우트를 가리키는 실제 `action`을 주고, `$fetch`로 향상해 JavaScript가 있는 독자는 페이지에 머물게 하세요. 라우트는 동의 체크박스와 `@nuxtjs/turnstile`의 Turnstile 토큰을 확인하고, 시크릿 키로 가입을 폼 백엔드로 전달하며, 요청한 쪽에 따라 JSON이나 리디렉션으로 응답합니다. 두 번째 라우트가 백엔드의 서명된 웹훅을 beehiiv 구독으로 바꿉니다.

[메인 가이드의 Nuxt 탭](https://ikiform.com/ko/guides/newsletter-signup.md#form)은 `$fetch`로 POST하며 Vue가 하이드레이션된 뒤에 동작합니다. 이 페이지는 같은 폼을 그 전에도 동작하게 하고, 사람 확인을 더하고, 실제 가입을 beehiiv로 보냅니다. 제품 설정은 [Nuxt 문서 가이드](https://ikiform.com/ko/docs/guides/nuxt.md)에 있습니다.

## 라우트 하나, 두 종류의 호출자

Nuxt 서버 라우트는 브라우저 폼과 `$fetch` 중 누가 호출했는지 모르므로 `Accept` 헤더로 판단합니다. 컴포넌트의 `$fetch`는 JSON을 요청하고 JSON이나 사유가 담긴 오류를 받습니다. 하이드레이션 전이나 스크립트가 꺼진 일반 폼 POST는 HTML을 요청하므로, 라우트가 결과 코드와 함께 `/newsletter`로 `303`을 응답합니다.

다이어그램: Nuxt 뉴스레터 라우트가 각 호출자에게 응답하는 방식

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 모듈을 설치한 뒤:

`프로젝트 트리`:

```text
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

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

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

## 만들기

### 서버 라우트

`server/api/newsletter.post.ts`:

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

### 폼 컴포넌트

`app/components/NewsletterForm.vue`:

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

```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 가이드](https://ikiform.com/ko/guides/newsletter-signup/beehiiv.md)는 이름과 beehiiv 자체의 레이트 리밋 헤더를 추가합니다.

`server/routes/hooks/ikiform.post.ts`:

```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는 여전히 서버 라우트에 도달합니다.

## 배포

| 호스트 | 해결 방법 |
| --- | --- |
| 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이 있는 터널로 개발 서버를 노출하세요.

## 작동 확인하기

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

## 질문

### Nuxt 폼이 JavaScript 없이 작동할 수 있나요?

네, 실제 action과 method를 유지한다면 됩니다. 이 페이지처럼 일반 폼 POST에는 리디렉션하고 $fetch에는 JSON을 반환하는 서버 라우트를 가리키게 하세요.

### nuxt generate가 뉴스레터 폼용 서버 라우트를 지원하나요?

아니요. nuxt generate는 정적 파일만 출력하므로 서버 라우트가 없습니다. routeRules와 함께 nuxt build로 정적 페이지를 사전 렌더링하거나, 브라우저에서 Ikiform으로 폼을 바로 POST하세요.

### 서버 라우트가 아니라 웹훅에서 구독자를 beehiiv로 보내는 이유는 무엇인가요?

서버 라우트는 Ikiform의 응답만 보는데, 스팸도 실제 가입과 같은 성공을 받습니다. beehiiv는 스팸 검사 뒤에야 독자를 알아야 하며, 웹훅이 발생하는 때가 바로 그때입니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [뉴스레터 가입 폼](https://ikiform.com/ko/guides/newsletter-signup.md): 직접 작성한 마크업으로 뉴스레터 가입 폼을 만들고, 동의를 기록하고, 봇 가입을 막고, 각 구독자를 Kit, MailerLite, beehiiv 등 어떤 도구로든 넘깁니다.
- [beehiiv](https://ikiform.com/ko/guides/newsletter-signup/beehiiv.md): 내 가입 폼을 beehiiv에 연결합니다. 릴레이가 더블 옵트인을 켜고, 위치를 UTM source로, 페이지를 referrer로 해서 각 구독을 만듭니다.
- [더블 옵트인](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md): Ikiform은 확인 이메일을 보내지 않으므로 더블 옵트인은 이메일 도구에서 실행됩니다. 릴레이가 각 가입을 미확인으로 추가하고 도구가 링크를 보냅니다.
- [SvelteKit 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/sveltekit.md): 레이아웃에 둔 SvelteKit 3 뉴스레터 폼입니다. 동의와 IP 한도가 있는 폼 액션, 페이지에 머무는 use:enhance, Kit 웹훅 라우트로 구성됩니다.
- [정적 사이트](https://ikiform.com/ko/guides/newsletter-signup/static-site.md): Hugo, Jekyll, Eleventy, Astro 정적 사이트에 뉴스레터 가입 폼을 추가하고, 이메일 도구의 API 키는 페이지에서 빼고, 확인은 도구에서 합니다.
- [Kit (구 ConvertKit)](https://ikiform.com/ko/guides/newsletter-signup/kit.md): 구독자를 만들고, 인센티브 이메일을 위해 Kit 폼에 추가하고, 가입 위치를 태그하는 릴레이로 내 뉴스레터 폼을 Kit에 연결합니다.
