새 SaaS용 대기자 명단6분 분량
Astro로 대기자 명단 만들기
정적 랜딩 페이지의 Astro 대기자 명단: Action이 각 가입을 전달하고, 스크립트가 이를 호출하며, 요청 시 렌더링되는 페이지 하나가 JavaScript 없는 POST를 받습니다.
랜딩 페이지는 프리렌더링된 상태로 두고, 서버 작업은 이메일을 검증하고 시크릿 키로 폼 백엔드에 전달하는 Astro Action에 넣으세요. 짧은 스크립트가 액션을 호출해 방문자를 페이지에 머물게 하고, 폼의 일반 action은 JavaScript가 로드되지 않았을 때 같은 액션을 실행하는, 요청 시 렌더링되는 페이지 하나를 가리킵니다.
메인 가이드에는 짧은 Astro 탭이 있습니다. 액션 하나, 폼 하나, 요청 시 렌더링되는 페이지 하나입니다. 작동은 하지만, 대부분의 사람이 Astro를 고르는 이유를 포기하게 됩니다. 랜딩 페이지는 CDN에서 정적 HTML로 제공하고 싶은 대표적인 페이지인데, Astro 폼 액션은 요청마다 렌더링하도록 강제합니다. 이 페이지는 랜딩 페이지를 정적으로 유지하고 가입만 서버로 옮깁니다.
Astro에서 다른 점
Astro는 기본적으로 모든 페이지를 프리렌더링합니다. 이 때문에 다른 프레임워크에는 없는 두 가지 영향이 대기자 명단에 생깁니다.
| Item | 프리렌더링된 랜딩 페이지 | 요청 시 렌더링되는 페이지 |
|---|---|---|
| 제공 위치 | CDN, 파일 형태 | 어댑터의 서버 또는 함수 |
<form action={actions.x}> | 불가. Astro는 요청 시 렌더링되는 페이지가 필요합니다 | JavaScript 없이 작동 |
URL의 ?ref= | 서버에서 볼 수 없음. 쿼리 없이 한 번만 빌드 | Astro.url에서 읽을 수 있음 |
Astro.clientAddress | 사용 불가 | 사용 가능 |
그래서 랜딩 페이지는 폼 액션 패턴을 쓸 수 없고, 빌드 시점에 ?ref=도 읽을 수 없습니다. 해결책은 역할을 나누는 것입니다. 정적 페이지는 폼과 스크립트를 담고, 액션은 서버 작업을 하고, 작은 페이지 하나인 /join이 요청 시 렌더링되어 JavaScript 없이 도착한 POST를 받습니다.
- 랜딩 페이지는 CDN에서 오는 일반 HTML입니다. 스크립트가 로드되면 ?ref= 또는 ?utm_source=를 숨겨진 ref 입력에 복사합니다.
- JavaScript가 있으면 스크립트가 일반 POST를 막고 폼 데이터로 actions.joinWaitlist를 호출합니다. 액션은 서버에서 실행되어 가입을 Ikiform에 전달합니다.
- 스크립트가 폼을 감사 문구로 바꾸거나 입력 아래에 오류를 보여 줍니다.
- JavaScript가 없으면 브라우저가 폼을 요청 시 렌더링되는 /join으로 POST합니다.
- /join은 Astro.callAction으로 같은 액션을 호출합니다. 성공하면 303으로 /thanks로 리디렉션하므로 새로 고침해도 다시 POST하지 않습니다. 오류가 나면 메시지와 함께 폼을 보여 줍니다.
프로젝트 설정하기
폼과 시크릿 키 만들기 같은 제품 설정은 Astro 문서 가이드에 있습니다. 이 페이지의 파일은 다음과 같습니다.
src/
actions/index.ts
components/WaitlistForm.astro
pages/index.astro
pages/join.astro
pages/thanks.astro
astro.config.mjs
.env어댑터와 타입이 지정된 시크릿
액션은 요청 시점에 서버에서 실행되므로, 대부분의 페이지가 정적이어도 프로젝트에는 어댑터가 필요합니다. astro add가 어댑터를 설치하고 설정을 수정합니다. 호스트에 맞게 node를 vercel, netlify, cloudflare로 바꾸세요.
npx astro add nodeimport { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
adapter: node({ mode: "standalone" }),
env: {
schema: {
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
},
},
});access: "secret"을 지정한 envField는 IKIFORM_SECRET_KEY를 astro:env/server에서만 가져올 수 있게 합니다. 클라이언트 스크립트에서 가져오면 빌드가 실패하는데, 그게 목적입니다. API keys에서 submissions:write 스코프로 키를 만드세요.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.필드 정의
액션은 email과 ref를 보냅니다. 폼의 Fields 탭에 필드 정의를 추가한다면 둘 다 넣어야 합니다. 그렇지 않으면 Ikiform이 422와 unknown_field로 POST를 거부합니다.
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}만들기
액션
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
const SOURCE = /^[a-z0-9_-]{1,40}$/;
export const server = {
joinWaitlist: defineAction({
accept: "form",
input: z.object({
email: z.email("Enter the email we should send your invite to."),
ref: z
.string()
.nullish()
.transform((value) => (value && SOURCE.test(value) ? value : "direct")),
_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,
ref: input.ref,
_gotcha: input._gotcha ?? "",
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
if (!response) {
throw new ActionError({ code: "GATEWAY_TIMEOUT", message: "We couldn't reach the list. Try again." });
}
if (response.status === 429) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Lots of people are joining. Try again in a minute." });
}
const result = await response.json().catch(() => null);
if (result?.ok) return { joined: true };
throw new ActionError({
code: "BAD_REQUEST",
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
});
},
}),
};accept: "form"은FormData를 파싱합니다. 빈 입력은null로 오므로ref와 허니팟은nullish()이고,ref가 없으면direct가 됩니다.ref는 영문 소문자, 숫자,-,_로 된 40자 이하여야 합니다. 그 밖의 값은direct로 저장되므로, 조작된 링크가 내보낸 파일을 쓰레기 값으로 채울 수 없습니다. 이 제한은 정의의max_length와 같습니다.z.email()은astro/zod에서 내보내는 Zod 4의 최상위 이메일 스키마입니다. 잘못된 주소는 여기서 실패하고 Ikiform에 도달하지 않습니다.- 브라우저는 스크립트 호출과 일반 POST 모두에
Origin헤더를 보냅니다. Ikiform은 이 헤더로 무료 테스트 제출을 세는데 액션 자체의fetch는 이 헤더를 보내지 않으므로, 액션이 이를 전달합니다. - 허니팟은 손대지 않고 그대로 전달되므로, Ikiform은 이를 채운 봇을 여전히 스팸으로 표시합니다. 시크릿 키를 쓴 POST는 Ikiform의 사람 확인을 건너뛰므로 Spam check를 켜도 여기서는 아무 효과가 없습니다. 액션이 관문입니다.
- 실패할 때마다 무엇이 잘못됐는지에 맞는 코드와 함께
ActionError를 던집니다. 스크립트와/join모두error.message를 읽습니다.
폼 컴포넌트
---
interface Props {
error?: string;
}
const { error = "" } = Astro.props;
---
<form method="POST" action="/join" data-waitlist>
<label>
Email
<input name="email" type="email" autocomplete="email" required placeholder="you@company.com" />
</label>
<input type="hidden" name="ref" value="direct" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p role="alert">{error}</p>
<button>Join the waitlist</button>
</form>
<script>
import { actions, isInputError } from "astro:actions";
const query = new URLSearchParams(location.search);
const source = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase().slice(0, 40);
for (const form of document.querySelectorAll<HTMLFormElement>("form[data-waitlist]")) {
const ref = form.elements.namedItem("ref") as HTMLInputElement;
const alert = form.querySelector<HTMLElement>("[role=alert]")!;
const button = form.querySelector("button")!;
if (source) ref.value = source;
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
button.textContent = "Joining";
alert.textContent = "";
const { error } = await actions.joinWaitlist(new FormData(form));
if (!error) {
const done = document.createElement("p");
done.setAttribute("role", "status");
done.textContent = "You're on the list. We'll email you on launch day.";
form.replaceWith(done);
return;
}
alert.textContent = isInputError(error) ? (error.fields.email?.[0] ?? "Check the address.") : error.message;
button.disabled = false;
button.textContent = "Join the waitlist";
});
}
</script>- 폼의
action은 일반 URL인/join입니다. JavaScript가 없으면 브라우저가 그곳으로 POST하므로, 스크립트가 로드되기 전이나 실패했을 때도 폼이 작동합니다. - 프리렌더링된 페이지는 쿼리 문자열 없이 빌드되었으므로 스크립트가 브라우저에서
?ref=를 읽습니다./?ref=newsletter에서 온 방문자는newsletter로 태그됩니다. - Astro는 컴포넌트의
<script>를 페이지마다 한 번만 번들링하므로, 반복문이 페이지의 모든 대기자 명단 폼을 연결합니다. 히어로에 하나, 푸터에 하나를 두어도 둘 다 작동합니다. - 라벨이
id를 가리키지 않고 입력을 감싸므로, 한 페이지에 두 개가 있어도 충돌하지 않습니다.
랜딩 페이지와 /join
---
import WaitlistForm from "../components/WaitlistForm.astro";
---
<main>
<h1>Acme is almost here</h1>
<p>Leave your email and we'll send you an invite the day it opens.</p>
<WaitlistForm />
</main>prerender 내보내기가 없으므로 정적으로 유지됩니다. /join은 이를 끄고 일반 POST를 처리합니다.
---
import { actions, isInputError } from "astro:actions";
import WaitlistForm from "../components/WaitlistForm.astro";
export const prerender = false;
let error = "";
if (Astro.request.method === "POST") {
const result = await Astro.callAction(actions.joinWaitlist, await Astro.request.formData());
if (!result.error) return Astro.redirect("/thanks", 303);
error = isInputError(result.error) ? (result.error.fields.email?.[0] ?? "Check the address.") : result.error.message;
Astro.response.status = 400;
}
---
<main>
<h1>Join the Acme waitlist</h1>
<WaitlistForm error={error} />
</main>Astro.callAction은 같은 검증으로 액션을 실행하므로 고칠 곳이 한 군데뿐입니다. 303은 브라우저를 GET으로 /thanks로 보내며, 이 페이지는 같은 감사 문구로 직접 작성하는 정적 페이지입니다.
배포 참고 사항
| 호스트 | 어댑터 | 시크릿 |
|---|---|---|
| Vercel | npx astro add vercel | 프로젝트의 Environment Variables에 IKIFORM_SECRET_KEY 추가 |
| Netlify | npx astro add netlify | Site configuration › Environment variables에 추가 |
| Cloudflare | npx astro add cloudflare | Worker 설정에서 시크릿으로 추가하거나 wrangler secret put IKIFORM_SECRET_KEY 사용 |
어느 호스트에서든 랜딩 페이지와 /thanks는 정적 파일로 나가고, 액션과 /join은 서버 함수 하나가 됩니다. 조용한 날에는 비용이 거의 들지 않고, 출시 때 몰리는 트래픽은 대부분 CDN이 받습니다.
작동 확인하기
astro dev를 실행하고http://localhost:4321/?ref=test를 여세요. 가입하세요. 새로 고침 없이 폼이 감사 문구로 바뀝니다.- 대시보드에서 Submissions를 여세요.
ref가test로 설정된 가입이 있습니다. - 브라우저에서 JavaScript를 끄고 새로 고친 뒤 다시 가입하세요.
/thanks로 이동하고, 새 가입의ref는direct로 설정됩니다. - JavaScript를 끈 상태로
not-an-email을 제출하세요./join이 이메일 오류와 함께 폼을 다시 보여 줍니다. - 허니팟을 채워서
_gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. astro dev에서 보낸 POST는localhost출처를 가지므로 테스트 제출로 집계됩니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도localhost에서 보낸 테스트 제출을 매달 50건 받습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 내세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
주의할 점
| 문제 | 해결 방법 |
|---|---|
일반 POST에서 내 사이트가 403을 반환함 | 기본으로 켜져 있는 Astro의 security.checkOrigin은 다른 출처에서 요청 시 렌더링 페이지로 보낸 폼 POST를 거부합니다. 다른 미리보기 도메인을 거치지 말고, 개발 중에는 localhost:4321처럼 사이트가 실행되는 같은 호스트에서 POST하세요. |
| 프리렌더링된 페이지의 폼 액션에 관한 빌드 오류 | 정적 페이지의 폼이 action={actions.joinWaitlist}를 씁니다. 위처럼 action을 /join으로 유지하거나, 그 페이지에 export const prerender = false를 추가하세요. |
ref가 항상 direct임 | 링크의 값에 대문자나 공백이 있거나 스크립트가 로드되지 않았습니다. 스크립트는 값을 소문자로 바꾸고, a-z, 0-9, -, _ 이외의 값은 액션이 버립니다. |
unknown_field와 함께 422 | 폼의 필드 정의에 ref가 없습니다. 위의 정의처럼 추가하세요. |
402 plan_required | 워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. Settings › Plan and usage에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |
질문
네. 랜딩 페이지는 프리렌더링된 상태로 두고, 클라이언트 스크립트에서 액션을 호출하고, 폼의 일반 action은 요청 시 렌더링되는 페이지 하나를 가리키게 하세요. 서버가 필요한 것은 액션과 그 페이지뿐입니다.
네. 액션은 호출될 때 서버에서 실행되므로, 모든 페이지가 프리렌더링되더라도 Node, Vercel, Netlify, Cloudflare 같은 어댑터가 필요합니다.
해도 됩니다. 그러면 Ikiform의 방문자별 레이트 리밋과 사람 확인이 유지됩니다. 유입 경로 태그를 먼저 검증하고 정리하고 싶거나 엔드포인트를 페이지 HTML에서 숨기고 싶을 때 액션을 쓸 가치가 있습니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.