새 SaaS용 대기자 명단

새 SaaS용 대기자 명단6분 분량

Astro로 대기자 명단 만들기

정적 랜딩 페이지의 Astro 대기자 명단: Action이 각 가입을 전달하고, 스크립트가 이를 호출하며, 요청 시 렌더링되는 페이지 하나가 JavaScript 없는 POST를 받습니다.

Preet SutharIkiform 창업자

랜딩 페이지는 프리렌더링된 상태로 두고, 서버 작업은 이메일을 검증하고 시크릿 키로 폼 백엔드에 전달하는 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를 받습니다.

정적 랜딩 페이지에서 Astro Action 하나로 들어가는 두 경로JavaScript 있음JavaScript 없음정적 페이지스크립트가URL에서 ref채우기actions.joinWaitlist()서버에서 실행Ikiform시크릿 키, 저장감사 문구같은 페이지정적 페이지폼이 /join으로POST/join요청 시 렌더링,Astro.callActionIkiform같은 액션/thanks303 리디렉션FormDataJSON결과POSTJSON303정적 랜딩 페이지에서 Astro Action 하나로 들어가는 두 경로JavaScript 있음JavaScript 없음정적 페이지스크립트가 URL에서 ref 채우기actions.joinWaitlist()서버에서 실행Ikiform시크릿 키, 저장감사 문구같은 페이지정적 페이지폼이 /join으로 POST/join요청 시 렌더링, Astro.callActionIkiform같은 액션/thanks303 리디렉션FormDataJSON결과POSTJSON303
  1. 랜딩 페이지는 CDN에서 오는 일반 HTML입니다. 스크립트가 로드되면 ?ref= 또는 ?utm_source=를 숨겨진 ref 입력에 복사합니다.
  2. JavaScript가 있으면 스크립트가 일반 POST를 막고 폼 데이터로 actions.joinWaitlist를 호출합니다. 액션은 서버에서 실행되어 가입을 Ikiform에 전달합니다.
  3. 스크립트가 폼을 감사 문구로 바꾸거나 입력 아래에 오류를 보여 줍니다.
  4. JavaScript가 없으면 브라우저가 폼을 요청 시 렌더링되는 /join으로 POST합니다.
  5. /join은 Astro.callAction으로 같은 액션을 호출합니다. 성공하면 303으로 /thanks로 리디렉션하므로 새로 고침해도 다시 POST하지 않습니다. 오류가 나면 메시지와 함께 폼을 보여 줍니다.
액션 하나, 들어오는 길 둘. 서버가 필요한 것은 /join과 액션뿐입니다.

프로젝트 설정하기

폼과 시크릿 키 만들기 같은 제품 설정은 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 node
astro.config.mjs
import { 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 스코프로 키를 만드세요.

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_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 } }
  ]
}

만들기

액션

src/actions/index.ts
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를 읽습니다.

폼 컴포넌트

src/components/WaitlistForm.astro
---
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

src/pages/index.astro
---
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를 처리합니다.

src/pages/join.astro
---
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로 보내며, 이 페이지는 같은 감사 문구로 직접 작성하는 정적 페이지입니다.

배포 참고 사항

호스트어댑터시크릿
Vercelnpx astro add vercel프로젝트의 Environment Variables에 IKIFORM_SECRET_KEY 추가
Netlifynpx astro add netlifySite configuration › Environment variables에 추가
Cloudflarenpx astro add cloudflareWorker 설정에서 시크릿으로 추가하거나 wrangler secret put IKIFORM_SECRET_KEY 사용

어느 호스트에서든 랜딩 페이지와 /thanks는 정적 파일로 나가고, 액션과 /join은 서버 함수 하나가 됩니다. 조용한 날에는 비용이 거의 들지 않고, 출시 때 몰리는 트래픽은 대부분 CDN이 받습니다.

작동 확인하기

  1. astro dev를 실행하고 http://localhost:4321/?ref=test를 여세요. 가입하세요. 새로 고침 없이 폼이 감사 문구로 바뀝니다.
  2. 대시보드에서 Submissions를 여세요. ref가 test로 설정된 가입이 있습니다.
  3. 브라우저에서 JavaScript를 끄고 새로 고친 뒤 다시 가입하세요. /thanks로 이동하고, 새 가입의 ref는 direct로 설정됩니다.
  4. JavaScript를 끈 상태로 not-an-email을 제출하세요. /join이 이메일 오류와 함께 폼을 다시 보여 줍니다.
  5. 허니팟을 채워서 _gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
  6. 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에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다.

질문

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