뉴스레터 가입 폼6분 분량
Astro로 뉴스레터 가입 폼 만들기
사전 렌더링 페이지에 그대로 두는 Astro 뉴스레터 폼입니다. Astro Action이 동의를 확인해 Ikiform으로 POST하고, 엔드포인트가 독자를 MailerLite에 추가합니다.
Astro 페이지는 사전 렌더링한 채로 두고 가입 폼을 푸터 컴포넌트에 넣으세요. JavaScript가 없으면 Astro Action을 실행하는 요청 시 렌더링 /newsletter 페이지 하나로 POST하고, JavaScript가 있으면 작은 스크립트가 같은 액션을 호출해 독자가 글을 떠나지 않습니다. 액션은 astro/zod로 이메일과 동의를 검증하고 시크릿 키로 폼 백엔드에 POST하며, API 엔드포인트가 검증된 가입을 MailerLite로 전달합니다.
메인 가이드의 Astro 탭은 요청 시 렌더링 페이지에서 Action을 씁니다. 대부분의 Astro 사이트는 모든 페이지가 사전 렌더링되는 블로그나 문서이고, 푸터 폼은 그 모든 페이지에 있어야 합니다. 이 페이지는 그 페이지들을 정적으로 유지하면서도 가입은 서버에서 실행합니다. 키와 허용 도메인은 Astro 문서 가이드에 있습니다.
정적 페이지가 바꾸는 것
Astro 문서에 따르면 폼의 action 속성으로 액션을 호출하려면 페이지가 요청 시 렌더링되어야 합니다. 사전 렌더링된 글은 뒤에 서버가 없으므로 그 POST를 처리할 수 없습니다. 우회하는 방법이 두 가지 있고, 이 구성은 둘 다 씁니다.
| 경로 | 실행 시점 | 독자가 보는 것 |
|---|---|---|
스크립트가 actions.subscribe() 호출 | JavaScript 로드됨, 일반적인 경우 | 폼 아래 상태 문구가 바뀝니다. 글에 머뭅니다 |
폼이 /newsletter?_action=...로 POST | JavaScript 꺼짐 또는 아직 로드 안 됨 | 결과나 오류가 표시된 요청 시 렌더링 /newsletter 페이지 |
요청 시 실행되는 것은 /newsletter와 웹훅 엔드포인트뿐입니다. 모든 글은 정적 파일로 남고, 어댑터가 그 두 라우트용 서버 함수를 추가합니다.
- JavaScript가 있으면 푸터 스크립트가 일반 POST를 막고 폼 데이터로 액션을 호출합니다.
- 액션이 입력을 검증하고 Ikiform으로 POST한 뒤 스크립트에 데이터나 오류를 반환하고, 스크립트가 상태 문구를 업데이트합니다.
- JavaScript가 없으면 브라우저가 쿼리 문자열에 액션을 담아 요청 시 렌더링 /newsletter 페이지로 폼을 POST합니다.
- Astro가 같은 액션 핸들러를 실행하고, 페이지가 getActionResult로 결과를 읽어 보여 줍니다.
프로젝트 설정
src/
actions/index.ts
components/SignupForm.astro
layouts/Base.astro
pages/
newsletter.astro
api/ikiform.ts
astro.config.mjs
.envastro add vercel, astro add netlify, astro add cloudflare로 어댑터를 설치하세요. 시크릿은 import.meta.env를 읽지 말고 astro:env로 선언하세요. 변수가 없으면 스키마가 빌드를 실패시키고, 같은 import가 Cloudflare 런타임을 포함한 모든 어댑터에서 동작합니다.
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" }),
},
},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789API keys에서 submissions:write 스코프로 시크릿 키를 만들고, 폼의 Fields 탭에 필러의 필드 정의를 붙여 넣으세요.
만들기
액션
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로 의미를 유지합니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.푸터 컴포넌트
---
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을 만드는 문서화된 방법입니다./newsletterURL에 설정하면 JavaScript 없는 POST가 출발한 정적 페이지 대신 요청 시 렌더링 페이지로 갑니다.- Astro는 컴포넌트가 몇 번 나타나든 컴포넌트의
<script>를 한 번만 번들링합니다. 루프가 푸터 폼과 글 끝의 폼 같은 모든 사본을 연결합니다. isInputError는 zod의 필드별 메시지와, 일반 메시지로 도착하는 Ikiform의 거부를 구분합니다.
요청 시 렌더링 페이지
---
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 가이드의 릴레이를 쓰세요. 주소를 먼저 조회하므로 다시 가입한 확인된 독자가 상태를 유지합니다.
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로 전달하지 않습니다.
작동 확인하기
astro build를 실행하고 출력에서 글들이 여전히 사전 렌더링으로 나오고/newsletter와/api/ikiform만 요청 시 렌더링인지 확인하세요.- 글의 푸터에서 구독하세요. 상태 문구가 바뀌고 URL은 바뀌지 않습니다.
- JavaScript를 끄고 다시 구독하세요. 받은편지함을 확인하라는 문구가 있는
/newsletter로 이동합니다. - 개발자 도구에서 체크박스를 비운 채 제출하세요. 동의 메시지가 표시되고 아무것도 저장되지 않습니다.
- 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
- 웹훅을 추가했다면 Webhooks에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께
succeeded로 표시됩니다. - 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 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
질문
프로젝트에 어댑터가 있다면 클라이언트 스크립트에서 호출하는 것은 됩니다. 액션은 자체 요청 시 렌더링 라우트에서 실행됩니다. 일반 HTML 폼 POST에는 도착할 요청 시 렌더링 페이지가 필요하므로 이 구성은 /newsletter로 보냅니다.
이 페이지의 서버 측 버전에만 필요합니다. 완전히 정적인 Astro 사이트는 브라우저에서 Ikiform으로 바로 폼을 POST할 수 있으며, 이 경우 Ikiform의 사람 확인이 유지되고 서버가 필요 없습니다.
액션은 봇과 독자를 구분할 수 없습니다. Ikiform은 둘 다 같은 성공 응답을 줍니다. 웹훅은 스팸 검사를 통과한 가입에만 발생하며, MailerLite가 다운되어도 방문자의 제출이 실패하는 대신 웹훅 재시도로 구독자 추가가 지연될 뿐입니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.