# Astro로 대기자 명단 만들기

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

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

랜딩 페이지는 프리렌더링된 상태로 두고, 서버 작업은 이메일을 검증하고 시크릿 키로 폼 백엔드에 전달하는 Astro Action에 넣으세요. 짧은 스크립트가 액션을 호출해 방문자를 페이지에 머물게 하고, 폼의 일반 `action`은 JavaScript가 로드되지 않았을 때 같은 액션을 실행하는, 요청 시 렌더링되는 페이지 하나를 가리킵니다.

[메인 가이드](https://ikiform.com/ko/guides/waitlist.md#form)에는 짧은 Astro 탭이 있습니다. 액션 하나, 폼 하나, 요청 시 렌더링되는 페이지 하나입니다. 작동은 하지만, 대부분의 사람이 Astro를 고르는 이유를 포기하게 됩니다. 랜딩 페이지는 CDN에서 정적 HTML로 제공하고 싶은 대표적인 페이지인데, Astro 폼 액션은 요청마다 렌더링하도록 강제합니다. 이 페이지는 랜딩 페이지를 정적으로 유지하고 가입만 서버로 옮깁니다.

## Astro에서 다른 점

Astro는 기본적으로 모든 페이지를 프리렌더링합니다. 이 때문에 다른 프레임워크에는 없는 두 가지 영향이 대기자 명단에 생깁니다.

|   | 프리렌더링된 랜딩 페이지 | 요청 시 렌더링되는 페이지 |
| --- | --- | --- |
| 제공 위치 | CDN, 파일 형태 | 어댑터의 서버 또는 함수 |
| `<form action={actions.x}>` | 불가. Astro는 요청 시 렌더링되는 페이지가 필요합니다 | JavaScript 없이 작동 |
| URL의 `?ref=` | 서버에서 볼 수 없음. 쿼리 없이 한 번만 빌드 | `Astro.url`에서 읽을 수 있음 |
| `Astro.clientAddress` | 사용 불가 | 사용 가능 |

그래서 랜딩 페이지는 폼 액션 패턴을 쓸 수 없고, 빌드 시점에 `?ref=`도 읽을 수 없습니다. 해결책은 역할을 나누는 것입니다. 정적 페이지는 폼과 스크립트를 담고, 액션은 서버 작업을 하고, 작은 페이지 하나인 `/join`이 요청 시 렌더링되어 JavaScript 없이 도착한 POST를 받습니다.

다이어그램: 정적 랜딩 페이지에서 Astro Action 하나로 들어가는 두 경로

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 문서 가이드](https://ikiform.com/ko/docs/guides/astro.md)에 있습니다. 이 페이지의 파일은 다음과 같습니다.

`프로젝트`:

```text
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`로 바꾸세요.

`터미널`:

```bash
npx astro add node
```

`astro.config.mjs`:

```js
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`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

### 필드 정의

액션은 `email`과 `ref`를 보냅니다. 폼의 **Fields** 탭에 필드 정의를 추가한다면 둘 다 넣어야 합니다. 그렇지 않으면 Ikiform이 `422`와 `unknown_field`로 POST를 거부합니다.

`필드`:

```json
{
  "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`:

```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`:

```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`:

```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`:

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

## 배포 참고 사항

| 호스트 | 어댑터 | 시크릿 |
| --- | --- | --- |
| 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이 받습니다.

## 작동 확인하기

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를 내세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 일반 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**에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |

## 질문

### Astro 대기자 명단 페이지를 정적으로 유지할 수 있나요?

네. 랜딩 페이지는 프리렌더링된 상태로 두고, 클라이언트 스크립트에서 액션을 호출하고, 폼의 일반 action은 요청 시 렌더링되는 페이지 하나를 가리키게 하세요. 서버가 필요한 것은 액션과 그 페이지뿐입니다.

### Astro Actions에 어댑터가 필요한가요?

네. 액션은 호출될 때 서버에서 실행되므로, 모든 페이지가 프리렌더링되더라도 Node, Vercel, Netlify, Cloudflare 같은 어댑터가 필요합니다.

### Astro 폼을 Ikiform으로 바로 POST하면 안 되나요?

해도 됩니다. 그러면 Ikiform의 방문자별 레이트 리밋과 사람 확인이 유지됩니다. 유입 경로 태그를 먼저 검증하고 정리하고 싶거나 엔드포인트를 페이지 HTML에서 숨기고 싶을 때 액션을 쓸 가치가 있습니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [새 SaaS용 대기자 명단](https://ikiform.com/ko/guides/waitlist.md): 내 랜딩 페이지에 출시 전 대기자 명단을 만듭니다. 폼, 스팸 방지, 중복 처리, 확인 이메일, 출시일의 깔끔한 내보내기까지 다룹니다.
- [Next.js 대기자 명단](https://ikiform.com/ko/guides/waitlist/nextjs.md): Next.js App Router 대기자 명단: 시크릿 키로 가입을 전달하는 서버 액션, useActionState, 낙관적 감사 문구, 방문자별 한도를 다룹니다.
- [일반 HTML 대기자 명단](https://ikiform.com/ko/guides/waitlist/html.md): 빌드 없이 일반 HTML로 만든 완전한 대기자 명단 페이지로, JavaScript가 꺼져도 작동하고, 스크립트가 로드되면 인라인 감사 문구를 보여 주며, 필요할 때는 기본 방식으로 돌아갑니다.
- [Product Hunt 출시 리스트](https://ikiform.com/ko/guides/waitlist/product-hunt.md): Product Hunt 몇 주 전부터 출시 리스트를 모으고, 가입마다 채널 태그를 달고, 태평양 시간 리셋에 맞춰 출시일 이메일을 두 번에 나눠 보냅니다.
- [선별 기능이 있는 베타 신청](https://ikiform.com/ko/guides/waitlist/beta-signup.md): 서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.
- [모바일 앱 대기자 명단](https://ikiform.com/ko/guides/waitlist/mobile-app.md): iOS나 Android 앱의 출시 전 대기자 명단으로, 사용하는 휴대폰을 묻고 iPhone 테스터는 TestFlight로 보내고 Play 비공개 테스트를 채웁니다.
