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

> 사전 렌더링 페이지에 그대로 두는 Astro 뉴스레터 폼입니다. Astro Action이 동의를 확인해 Ikiform으로 POST하고, 엔드포인트가 독자를 MailerLite에 추가합니다.

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

Astro 페이지는 사전 렌더링한 채로 두고 가입 폼을 푸터 컴포넌트에 넣으세요. JavaScript가 없으면 Astro Action을 실행하는 요청 시 렌더링 `/newsletter` 페이지 하나로 POST하고, JavaScript가 있으면 작은 스크립트가 같은 액션을 호출해 독자가 글을 떠나지 않습니다. 액션은 `astro/zod`로 이메일과 동의를 검증하고 시크릿 키로 폼 백엔드에 POST하며, API 엔드포인트가 검증된 가입을 MailerLite로 전달합니다.

[메인 가이드의 Astro 탭](https://ikiform.com/ko/guides/newsletter-signup.md#form)은 요청 시 렌더링 페이지에서 Action을 씁니다. 대부분의 Astro 사이트는 모든 페이지가 사전 렌더링되는 블로그나 문서이고, 푸터 폼은 그 모든 페이지에 있어야 합니다. 이 페이지는 그 페이지들을 정적으로 유지하면서도 가입은 서버에서 실행합니다. 키와 허용 도메인은 [Astro 문서 가이드](https://ikiform.com/ko/docs/guides/astro.md)에 있습니다.

## 정적 페이지가 바꾸는 것

Astro 문서에 따르면 폼의 `action` 속성으로 액션을 호출하려면 페이지가 요청 시 렌더링되어야 합니다. 사전 렌더링된 글은 뒤에 서버가 없으므로 그 POST를 처리할 수 없습니다. 우회하는 방법이 두 가지 있고, 이 구성은 둘 다 씁니다.

| 경로 | 실행 시점 | 독자가 보는 것 |
| --- | --- | --- |
| 스크립트가 `actions.subscribe()` 호출 | JavaScript 로드됨, 일반적인 경우 | 폼 아래 상태 문구가 바뀝니다. 글에 머뭅니다 |
| 폼이 `/newsletter?_action=...`로 POST | JavaScript 꺼짐 또는 아직 로드 안 됨 | 결과나 오류가 표시된 요청 시 렌더링 `/newsletter` 페이지 |

요청 시 실행되는 것은 `/newsletter`와 웹훅 엔드포인트뿐입니다. 모든 글은 정적 파일로 남고, 어댑터가 그 두 라우트용 서버 함수를 추가합니다.

다이어그램: Astro 푸터 가입이 같은 액션에 도달하는 두 가지 방법

1. JavaScript가 있으면 푸터 스크립트가 일반 POST를 막고 폼 데이터로 액션을 호출합니다.
2. 액션이 입력을 검증하고 Ikiform으로 POST한 뒤 스크립트에 데이터나 오류를 반환하고, 스크립트가 상태 문구를 업데이트합니다.
3. JavaScript가 없으면 브라우저가 쿼리 문자열에 액션을 담아 요청 시 렌더링 /newsletter 페이지로 폼을 POST합니다.
4. Astro가 같은 액션 핸들러를 실행하고, 페이지가 getActionResult로 결과를 읽어 보여 줍니다.

핸들러 하나, 진입점 둘. 정적 글에는 자체 서버가 전혀 필요 없습니다.

## 프로젝트 설정

`프로젝트 트리`:

```text
src/
  actions/index.ts
  components/SignupForm.astro
  layouts/Base.astro
  pages/
    newsletter.astro
    api/ikiform.ts
astro.config.mjs
.env
```

`astro add vercel`, `astro add netlify`, `astro add cloudflare`로 어댑터를 설치하세요. 시크릿은 `import.meta.env`를 읽지 말고 `astro:env`로 선언하세요. 변수가 없으면 스키마가 빌드를 실패시키고, 같은 import가 Cloudflare 런타임을 포함한 모든 어댑터에서 동작합니다.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_WEBHOOK_SECRET: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_API_KEY: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_GROUP_ID: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

**API keys**에서 `submissions:write` 스코프로 시크릿 키를 만들고, 폼의 **Fields** 탭에 [필러의 필드 정의](https://ikiform.com/ko/guides/newsletter-signup.md#fields)를 붙여 넣으세요.

## 만들기

### 액션

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z.email({ error: "Enter an email address we can send the newsletter to." }),
      first_name: z.string().trim().max(80).nullish(),
      consent: z.boolean().refine((ticked) => ticked, { error: "Tick the box so we know you want the emails." }),
      source: z.enum(["footer", "post", "newsletter-page"]).catch("footer"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          email: input.email,
          first_name: input.first_name || undefined,
          consent: true,
          source: input.source,
          _gotcha: input._gotcha ?? "",
        }),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { subscribed: true };
      throw new ActionError({
        code: response?.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "We couldn't reach the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"`은 Astro가 `FormData`를 파싱하게 합니다. 체크박스는 boolean 변환을 거치므로 체크하면 `true`, 체크하지 않으면 `false`이고, `refine`이 이를 동의 메시지로 바꿉니다.
- 빈 텍스트 입력은 `null`로 도착하므로 선택 필드는 `optional()`이 아니라 `nullish()`를 씁니다.
- `source`는 목록 밖의 값이면 `footer`로 대체되므로 조작된 POST가 위치를 지어낼 수 없습니다.
- `Origin` 줄은 브라우저의 origin을 전달합니다. Ikiform은 이 헤더로 무료 `localhost` 테스트를 알아보는데, 서버의 `fetch`는 이를 보내지 않기 때문입니다.
- Ikiform의 오류는 `ActionError`가 됩니다. `429`는 `TOO_MANY_REQUESTS`로 의미를 유지합니다.

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

### 푸터 컴포넌트

`src/components/SignupForm.astro`:

```astro
---
import { actions } from "astro:actions";

interface Props {
  source?: "footer" | "post" | "newsletter-page";
}

const { source = "footer" } = Astro.props;
const target = new URL("/newsletter", Astro.url);
target.search = actions.subscribe.queryString;
---

<form class="signup" method="POST" action={target.pathname + target.search}>
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
  <label>First name (optional) <input name="first_name" maxlength="80" autocomplete="given-name" /></label>
  <label>
    <input name="consent" type="checkbox" required />
    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 />
  <p class="signup-status" role="status"></p>
  <button>Subscribe</button>
</form>

<script>
  import { actions, isInputError } from "astro:actions";

  for (const form of document.querySelectorAll<HTMLFormElement>("form.signup")) {
    const status = form.querySelector(".signup-status") as HTMLElement;
    const button = form.querySelector("button") as HTMLButtonElement;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      status.textContent = "Subscribing";
      const { error } = await actions.subscribe(new FormData(form));
      button.disabled = false;
      if (!error) {
        form.replaceChildren(status);
        status.textContent = "Almost done. Open the email we sent you and confirm your address.";
        return;
      }
      const fields = isInputError(error) ? error.fields : {};
      status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? error.message;
    });
  }
</script>
```

- `queryString`은 액션 URL을 만드는 문서화된 방법입니다. `/newsletter` URL에 설정하면 JavaScript 없는 POST가 출발한 정적 페이지 대신 요청 시 렌더링 페이지로 갑니다.
- Astro는 컴포넌트가 몇 번 나타나든 컴포넌트의 `<script>`를 한 번만 번들링합니다. 루프가 푸터 폼과 글 끝의 폼 같은 모든 사본을 연결합니다.
- `isInputError`는 zod의 필드별 메시지와, 일반 메시지로 도착하는 Ikiform의 거부를 구분합니다.

### 요청 시 렌더링 페이지

`src/pages/newsletter.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import SignupForm from "../components/SignupForm.astro";
import Base from "../layouts/Base.astro";

export const prerender = false;

const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
const problem = fields.email?.[0] ?? fields.consent?.[0] ?? result?.error?.message;
---

<Base title="Newsletter">
  <h1>The monthly newsletter</h1>
  {result?.data?.subscribed ? (
    <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
  ) : (
    <>
      {problem && <p role="alert">{problem}</p>}
      <SignupForm source="newsletter-page" />
    </>
  )}
</Base>
```

Astro는 그 POST에 대해서만 액션 결과를 보여 줍니다. 페이지를 다시 방문하면 사라지며, 가입에는 괜찮습니다. 감사 URL로 리디렉션하고 싶다면 Astro 문서에 미들웨어의 `getActionContext`를 쓰는 세션 기반 패턴이 있습니다.

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

이 엔드포인트는 Ikiform의 서명된 웹훅을 받아 각 가입을 MailerLite 그룹에 `unconfirmed`로 추가하므로, **Double opt-in for API and integrations**가 켜져 있으면 MailerLite가 자체 확인 이메일을 보냅니다. 서명은 정확한 바이트를 대상으로 하므로 본문을 먼저 텍스트로 읽습니다. 모든 가입을 `unconfirmed`로 보내므로 새 목록에 맞습니다. 목록에 이미 확인된 독자가 있다면 대신 [MailerLite 가이드](https://ikiform.com/ko/guides/newsletter-signup/mailerlite.md)의 릴레이를 쓰세요. 주소를 먼저 조회하므로 다시 가입한 확인된 독자가 상태를 유지합니다.

`src/pages/api/ikiform.ts`:

```ts
import type { APIRoute } from "astro";
import { IKIFORM_WEBHOOK_SECRET, MAILERLITE_API_KEY, MAILERLITE_GROUP_ID } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: 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 answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

기본으로 켜진 Astro의 `security.checkOrigin`은 요청 시 렌더링 라우트로의 크로스 사이트 폼 POST를 거부합니다. Ikiform의 웹훅은 이 확인이 적용되지 않는 타입인 `application/json`이므로 엔드포인트가 그대로 받습니다. **Webhooks**에 URL을 추가하고 `whsec_` 시크릿을 `IKIFORM_WEBHOOK_SECRET`에 복사하세요.

## 허니팟과 사람 확인

`_gotcha` 입력은 전달되고, Ikiform은 값이 채워진 것을 성공 응답과 함께 웹훅 없이 스팸으로 저장하므로 MailerLite에 닿지 않습니다. 시크릿 키는 Ikiform의 Turnstile 확인을 건너뜁니다. 봇이 통과한다면 선택지는 두 가지입니다. [Turnstile 가이드](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)처럼 핸들러에서 Turnstile 토큰을 검증하거나, 액션을 빼고 `@ikiform/sdk`로 브라우저에서 POST하는 것입니다. 후자는 Ikiform의 확인, 허용 도메인, 방문자당 한도를 유지하며 어댑터가 전혀 필요 없습니다.

## 배포

| 호스트 | 해결 방법 |
| --- | --- |
| Vercel | `@astrojs/vercel`은 요청 시 렌더링 라우트 두 개를 함수로 바꿉니다. **Environment Variables**에 변수 네 개를 추가하세요. |
| Netlify | `@astrojs/netlify`는 Netlify Functions로 같은 일을 합니다. 사이트의 환경 설정에 변수를 추가하세요. |
| Cloudflare | `@astrojs/cloudflare`는 Workers를 대상으로 하며, 어댑터는 더 이상 Pages를 지원하지 않습니다. `wrangler secret put`으로 시크릿을 설정하고 로컬에서는 `.dev.vars`를 쓰세요. Ikiform은 `workers.dev` 웹훅 URL을 거부하므로 Worker에 커스텀 도메인을 주세요. |

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

플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받습니다. `astro dev`를 실행하면 전달되는 `Origin`이 `http://localhost:4321`이므로 각 가입이 테스트로 집계됩니다. 두 경로를 모두 시험하세요. 한 번은 평소대로, 한 번은 JavaScript를 끄고 `/newsletter` 페이지를 보세요. 엔드포인트를 테스트하려면 HTTPS URL이 있는 터널로 노출하세요. Ikiform은 `localhost`로 전달하지 않습니다.

## 작동 확인하기

1. `astro build`를 실행하고 출력에서 글들이 여전히 사전 렌더링으로 나오고 `/newsletter`와 `/api/ikiform`만 요청 시 렌더링인지 확인하세요.
2. 글의 푸터에서 구독하세요. 상태 문구가 바뀌고 URL은 바뀌지 않습니다.
3. JavaScript를 끄고 다시 구독하세요. 받은편지함을 확인하라는 문구가 있는 `/newsletter`로 이동합니다.
4. 개발자 도구에서 체크박스를 비운 채 제출하세요. 동의 메시지가 표시되고 아무것도 저장되지 않습니다.
5. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
6. 웹훅을 추가했다면 **Webhooks**에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께 `succeeded`로 표시됩니다.
7. MailerLite를 여세요. 구독자가 그룹에 미확인으로 있고 확인 이메일이 받은편지함에 있습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| JavaScript 없는 POST가 404나 정적 페이지를 보여 줌 | 폼이 출발한 사전 렌더링 페이지로 POST했습니다. `/newsletter`에 `queryString`을 더해 `action`을 만들고, 그 페이지에 `prerender = false`를 유지하세요. |
| 페이지의 첫 번째 폼만 동작함 | 스크립트가 `querySelector`로 폼 하나만 잡았습니다. Astro는 페이지당 한 번 실행하므로 모든 `form.signup`을 순회하세요. |
| 환경 변수가 없다며 빌드가 실패함 | `astro:env`가 빌드 시 스키마를 확인합니다. 호스트에, 로컬에서는 `.env`에 변수를 설정하세요. |
| 로컬 테스트에서 `402 plan_required` | POST에 `localhost` origin이 없었습니다. 핸들러의 `Origin` 줄을 확인하고 `localhost`로 사이트를 여세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |

## 질문

### Astro Action을 사전 렌더링 페이지에서 실행할 수 있나요?

프로젝트에 어댑터가 있다면 클라이언트 스크립트에서 호출하는 것은 됩니다. 액션은 자체 요청 시 렌더링 라우트에서 실행됩니다. 일반 HTML 폼 POST에는 도착할 요청 시 렌더링 페이지가 필요하므로 이 구성은 /newsletter로 보냅니다.

### Astro 뉴스레터 폼에 어댑터가 필요한가요?

이 페이지의 서버 측 버전에만 필요합니다. 완전히 정적인 Astro 사이트는 브라우저에서 Ikiform으로 바로 폼을 POST할 수 있으며, 이 경우 Ikiform의 사람 확인이 유지되고 서버가 필요 없습니다.

### 가입을 액션이 아니라 웹훅으로 MailerLite에 전달하는 이유는 무엇인가요?

액션은 봇과 독자를 구분할 수 없습니다. Ikiform은 둘 다 같은 성공 응답을 줍니다. 웹훅은 스팸 검사를 통과한 가입에만 발생하며, MailerLite가 다운되어도 방문자의 제출이 실패하는 대신 웹훅 재시도로 구독자 추가가 지연될 뿐입니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [뉴스레터 가입 폼](https://ikiform.com/ko/guides/newsletter-signup.md): 직접 작성한 마크업으로 뉴스레터 가입 폼을 만들고, 동의를 기록하고, 봇 가입을 막고, 각 구독자를 Kit, MailerLite, beehiiv 등 어떤 도구로든 넘깁니다.
- [정적 사이트](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에 연결합니다. 릴레이가 새 독자를 더블 옵트인을 위해 미확인으로 추가하고, 구독자에게 두 번 확인을 요청하지 않습니다.
- [Next.js 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/nextjs.md): Next.js 푸터 뉴스레터 폼입니다. 동의 확인과 Turnstile이 있는 서버 액션이 Ikiform으로 POST하고, 라우트 핸들러가 독자를 Kit에 추가합니다.
- [더블 옵트인](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md): Ikiform은 확인 이메일을 보내지 않으므로 더블 옵트인은 이메일 도구에서 실행됩니다. 릴레이가 각 가입을 미확인으로 추가하고 도구가 링크를 보냅니다.
- [Kit (구 ConvertKit)](https://ikiform.com/ko/guides/newsletter-signup/kit.md): 구독자를 만들고, 인센티브 이메일을 위해 Kit 폼에 추가하고, 가입 위치를 태그하는 릴레이로 내 뉴스레터 폼을 Kit에 연결합니다.
