뉴스레터 가입 폼

뉴스레터 가입 폼6분 분량

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

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

Preet SutharIkiform 창업자

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

메인 가이드의 Astro 탭은 요청 시 렌더링 페이지에서 Action을 씁니다. 대부분의 Astro 사이트는 모든 페이지가 사전 렌더링되는 블로그나 문서이고, 푸터 폼은 그 모든 페이지에 있어야 합니다. 이 페이지는 그 페이지들을 정적으로 유지하면서도 가입은 서버에서 실행합니다. 키와 허용 도메인은 Astro 문서 가이드에 있습니다.

정적 페이지가 바꾸는 것

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

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

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

Astro 푸터 가입이 같은 액션에 도달하는 두 가지 방법JavaScript 있음JavaScript 없음글사전렌더링된HTMLactions.subscribe()클라이언트스크립트에서액션핸들러zod,그다음fetch상태문구같은페이지글일반폼POST/newsletterprerender=false액션핸들러같은코드결과페이지getActionResultFormData/_actions/subscribe{ data, error }POST?_action결과Astro 푸터 가입이 같은 액션에 도달하는 두 가지 방법JavaScript 있음JavaScript 없음글사전 렌더링된 HTMLactions.subscribe()클라이언트 스크립트에서액션 핸들러zod, 그다음 fetch상태 문구같은 페이지글일반 폼 POST/newsletterprerender = false액션 핸들러같은 코드결과 페이지getActionResultFormData/_actions/subscribe{ data, error }POST?_action결과
  1. JavaScript가 있으면 푸터 스크립트가 일반 POST를 막고 폼 데이터로 액션을 호출합니다.
  2. 액션이 입력을 검증하고 Ikiform으로 POST한 뒤 스크립트에 데이터나 오류를 반환하고, 스크립트가 상태 문구를 업데이트합니다.
  3. JavaScript가 없으면 브라우저가 쿼리 문자열에 액션을 담아 요청 시 렌더링 /newsletter 페이지로 폼을 POST합니다.
  4. Astro가 같은 액션 핸들러를 실행하고, 페이지가 getActionResult로 결과를 읽어 보여 줍니다.
핸들러 하나, 진입점 둘. 정적 글에는 자체 서버가 전혀 필요 없습니다.

프로젝트 설정

프로젝트 트리
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
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
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 탭에 필러의 필드 정의를 붙여 넣으세요.

만들기

액션

src/actions/index.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 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

푸터 컴포넌트

src/components/SignupForm.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
---
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 가이드의 릴레이를 쓰세요. 주소를 먼저 조회하므로 다시 가입한 확인된 독자가 상태를 유지합니다.

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

질문

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