# Astro로 자격 확인 리드 폼 만들기

> Actions로 만든 Astro 리드 폼입니다. Zod 검증, 정적 사이트에서의 첫 유입 UTM, 요청 시 렌더링되는 문의 페이지, 스케줄러로의 리디렉션을 다룹니다.

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

마케팅 페이지는 사전 렌더링한 채로 두고, 첫 방문의 UTM 태그와 referrer는 클라이언트 스크립트로 작은 쿠키에 기록하세요. 사전 렌더링된 페이지의 미들웨어는 빌드 시에만 실행되기 때문입니다. 문의 페이지는 요청 시 렌더링하고, `astro/zod`로 Astro Action에서 리드를 검증한 뒤 유입 정보와 함께 폼 백엔드로 전달하세요. 액션에서 다음 URL을 반환하고 `Astro.redirect()`를 호출해 자격을 갖춘 리드를 스케줄러로 보내세요.

[메인 가이드의 Astro 탭](https://ikiform.com/ko/guides/lead-capture.md#form)은 액션으로 리드를 POST하고 감사 문구를 보여 줍니다. 이 페이지는 이를 영업용 폼으로 바꿉니다. 서버에서 Zod로 확인하는 회사 규모, 예산, 일정, 정적 사이트에서도 남는 캠페인 데이터, 그리고 적합한 리드를 캘린더로 넘기는 흐름입니다.

## Astro에서 달라지는 점

Astro는 기본적으로 페이지를 사전 렌더링하며, 이것이 유입 추적의 모든 것을 좌우합니다. 사전 렌더링 페이지의 미들웨어는 빌드 시 한 번, 방문자도 쿠키도 쿼리 문자열도 없이 실행됩니다. `?utm_source=`가 실제로 나타나는 랜딩 페이지는 거의 항상 사전 렌더링되므로 미들웨어는 태그를 보지 못합니다.

그래서 첫 유입은 대신 브라우저에서 레이아웃의 짧은 스크립트로 기록하고, 서버에서 액션이 다시 읽습니다. 문의 페이지는 요청 시 렌더링되어야 하는 유일한 페이지입니다. Astro 문서는 폼에서 액션을 호출하는 모든 페이지에 이를 요구합니다.

다이어그램: Astro가 리드 폼의 각 부분을 실행하는 위치

1. 빌드 시 미들웨어는 요청 데이터 없이 사전 렌더링 페이지에 대해 실행되므로 방문을 기록할 수 없습니다.
2. 브라우저에서 FirstTouch 스크립트가 첫 페이지 뷰의 UTM 태그와 referrer를 읽습니다.
3. 이후 모든 요청에 실리는 lead_source 쿠키에 한 번 저장합니다.
4. 문의 폼은 JavaScript 없이 일반 HTML 폼으로 액션에 POST합니다.
5. 액션이 Zod로 검증하고, 쿠키를 읽고, 리드를 Ikiform으로 전달합니다.
6. 페이지가 결과를 읽어 스케줄러나 감사 페이지로 리디렉션하고, 입력 오류는 해당 필드 아래에 렌더링합니다.

서버가 필요한 것은 문의 페이지뿐입니다. 방문자가 그 전에 읽는 모든 것은 정적으로 유지됩니다.

## 프로젝트 설정

`npx astro add vercel`(또는 `netlify`, `cloudflare`)로 어댑터를 추가한 뒤, `astro:env`로 키를 선언하세요. 그러면 Astro는 키 없이 빌드하지 않고 브라우저로 절대 보내지 않습니다. 폼과 키 만들기는 [Astro 문서 페이지](https://ikiform.com/ko/docs/guides/astro.md)에서 다룹니다.

`파일`:

```text
src/
  actions/index.ts
  components/FirstTouch.astro
  layouts/Base.astro
  pages/
    pricing.astro
    contact-sales.astro
    contact-sales/thanks.astro
astro.config.mjs
.env
```

`astro.config.mjs`:

```js
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" }),
    },
  },
});
```

`output`을 설정하지 않으면 모든 페이지가 사전 렌더링되고, `prerender = false`를 export하는 페이지만 서버에서 실행됩니다. 폼의 **Fields** 탭에 아래 필드를 추가하면 Ikiform이 같은 규칙을 확인하고 나머지는 거부합니다.

| 필드 ID | Ikiform의 타입 | 설정 주체 |
| --- | --- | --- |
| `name`, `email`, `company` | 텍스트, 이메일, 텍스트 | 방문자 |
| `company_size` | 선택형: `1_10`, `11_50`, `51_200`, `201_1000`, `1000_plus` | 방문자 |
| `budget` | 선택형: `under_10k`, `10k_50k`, `50k_plus`, `unsure` | 방문자 |
| `timeline` | 선택형: `this_quarter`, `next_quarter`, `later`, `researching` | 방문자 |
| `message` | Textarea, 선택 | 방문자 |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | 텍스트, 선택 | 서버, 첫 방문에서 |
| `fit` | 선택형: `qualified`, `nurture` | 서버, 검증 후 |

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

## 정적 사이트에서 첫 유입 기록하기

`src/components/FirstTouch.astro`:

```astro
<script>
  const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

  if (!document.cookie.split("; ").some((pair) => pair.startsWith("lead_source="))) {
    const params = new URLSearchParams(location.search);
    const from = URL.canParse(document.referrer) ? new URL(document.referrer) : null;
    const external = from && from.host !== location.host ? from.href.slice(0, 300) : "";
    const source = new URLSearchParams();
    for (const tag of TAGS) source.set(tag, (params.get(tag) ?? "").slice(0, 100));

    if (external || TAGS.some((tag) => source.get(tag))) {
      source.set("referrer", external);
      source.set("landing_page", location.pathname.slice(0, 200));
      document.cookie = `lead_source=${encodeURIComponent(source.toString())}; path=/; max-age=2592000; samesite=lax`;
    }
  }
</script>
```

- 모든 페이지가 쓰는 레이아웃에 `<FirstTouch />`를 넣으세요. Astro는 스크립트를 한 번 번들링해 페이지가 로드될 때마다 실행하며, 쿠키 확인 덕분에 첫 방문만 집계됩니다.
- 스크립트가 설정하므로 쿠키는 `httpOnly`가 아닙니다. 힌트로만 다루세요. 액션은 알려진 키 다섯 개만 읽고, Ikiform은 모든 필드의 길이를 제한합니다.
- 내 호스트에서 온 referrer는 무시하므로 **Pricing**에서 **Contact**로 클릭해도 실제 출처를 덮어쓰지 않습니다.
- 랜딩 페이지를 대신 요청 시 렌더링한다면 같은 로직을 `src/middleware.ts`에 `context.cookies.set()`으로 넣을 수 있습니다. 먼저 `context.isPrerendered`를 확인하고 건너뛰세요.

## 액션

`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 = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export const server = {
  submitLead: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
      email: z.email("Enter a work email we can reply to."),
      company: z.string().trim().min(1, "Which company is this for?").max(200),
      company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
      budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
      timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
      message: z.string().trim().max(5000).nullish(),
      _gotcha: z.string().nullish(),
    }),
    handler: async (lead, context) => {
      const stored = new URLSearchParams(context.cookies.get("lead_source")?.value ?? "");
      const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
      const qualified =
        lead.company_size !== "1_10" &&
        ["10k_50k", "50k_plus"].includes(lead.budget) &&
        ["this_quarter", "next_quarter"].includes(lead.timeline);

      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({ ...lead, ...source, fit: qualified ? "qualified" : "nurture" }),
      });
      const result = await response.json().catch(() => null);
      if (!result?.ok) {
        throw new ActionError({
          code: response.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
          message: result?.error?.message ?? "We couldn't send that. Email sales@example.com instead.",
        });
      }

      if (!qualified || lead._gotcha) return { next: "/contact-sales/thanks" };
      return { next: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` };
    },
  }),
};
```

- `accept: "form"`이면 Astro는 Zod가 보기 전에 빈 입력을 `null`로 바꿉니다. 그래서 선택하지 않은 `select`는 내 메시지로 `z.enum`에서 실패하고, 선택 텍스트에는 `nullish()`가 필요합니다.
- 브라우저의 `Origin` 헤더를 전달합니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더로 무료 `localhost` 테스트를 판별하기 때문입니다. `astro dev`에서는 `http://localhost:4321`입니다.
- 액션은 직접 리디렉션하지 않고 다음에 갈 곳을 반환합니다. 액션은 `redirect`를 호출할 수 없으므로 페이지가 결과를 보고 리디렉션합니다.
- Ikiform의 `429`는 `TOO_MANY_REQUESTS`가 되므로, 페이지가 바쁜 폼과 잘못된 입력을 구분할 수 있습니다.

## 문의 페이지

`src/pages/contact-sales.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import Base from "../layouts/Base.astro";

export const prerender = false;

const result = Astro.getActionResult(actions.submitLead);
if (result && !result.error) return Astro.redirect(result.data.next, 303);

const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
const failure = result?.error && !isInputError(result.error) ? result.error.message : "";
---

<Base title="Talk to sales">
  <form method="POST" action={actions.submitLead}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required />
    {fields.name && <p role="alert">{fields.name[0]}</p>}

    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {fields.email && <p role="alert">{fields.email[0]}</p>}

    <label for="company">Company</label>
    <input id="company" name="company" autocomplete="organization" required />
    {fields.company && <p role="alert">{fields.company[0]}</p>}

    <label for="company_size">Company size</label>
    <select id="company_size" name="company_size" required>
      <option value="">Choose one</option>
      <option value="1_10">1 to 10</option>
      <option value="11_50">11 to 50</option>
      <option value="51_200">51 to 200</option>
      <option value="201_1000">201 to 1,000</option>
      <option value="1000_plus">More than 1,000</option>
    </select>
    {fields.company_size && <p role="alert">{fields.company_size[0]}</p>}

    <label for="budget">Budget</label>
    <select id="budget" name="budget" required>
      <option value="">Choose one</option>
      <option value="under_10k">Under $10k</option>
      <option value="10k_50k">$10k to $50k</option>
      <option value="50k_plus">More than $50k</option>
      <option value="unsure">Not sure yet</option>
    </select>
    {fields.budget && <p role="alert">{fields.budget[0]}</p>}

    <label for="timeline">When do you want to start?</label>
    <select id="timeline" name="timeline" required>
      <option value="">Choose one</option>
      <option value="this_quarter">This quarter</option>
      <option value="next_quarter">Next quarter</option>
      <option value="later">Later this year</option>
      <option value="researching">Researching for now</option>
    </select>
    {fields.timeline && <p role="alert">{fields.timeline[0]}</p>}

    <label for="message">Anything we should know?</label>
    <textarea id="message" name="message" rows="4"></textarea>

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {failure && <p role="alert">{failure}</p>}
    <button>Talk to sales</button>
  </form>
</Base>

<script>
  const form = document.querySelector("form");
  form?.addEventListener("submit", () => {
    const button = form.querySelector("button");
    if (button) {
      button.disabled = true;
      button.textContent = "Sending";
    }
  });
</script>
```

폼은 일반 HTML 방식으로 POST하므로 JavaScript가 꺼져 있어도 동작합니다. Astro가 액션을 실행하고 결과와 함께 같은 페이지를 렌더링하며, frontmatter가 `303`으로 리디렉션하거나 오류를 렌더링합니다. `isInputError`는 Zod의 필드별 메시지와 액션 자체의 실패를 구분합니다. 작은 스크립트는 버튼만 비활성화해 더블 클릭으로 리드가 두 번 전송되지 않게 합니다.

### 허니팟과 사람 확인

허니팟은 스키마의 일부이고 그대로 전달되므로 Ikiform이 값이 채워진 것을 스팸으로 저장하며, 액션은 이를 캘린더가 아니라 감사 페이지로 보냅니다. 시크릿 키는 Ikiform 자체의 사람 확인을 건너뜁니다. 봇이 페이지를 찾아낸다면 [폼에 Turnstile 적용하기](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)처럼 핸들러에서 Turnstile 토큰을 검증하세요.

## 배포 참고 사항

| 호스트 | 알아 둘 점 |
| --- | --- |
| Vercel | `@astrojs/vercel`은 문의 페이지와 액션을 함수로 실행하고 나머지는 정적 파일로 제공합니다. 프로젝트의 환경 변수에 `IKIFORM_SECRET_KEY`를 추가하세요. |
| Netlify | `@astrojs/netlify`는 Netlify Functions로 같은 일을 합니다. 사이트의 환경 변수에 키를 설정하세요. |
| Cloudflare | `@astrojs/cloudflare`는 요청 시 렌더링 페이지를 Worker에서 실행합니다. `wrangler secret put`으로 키를 시크릿으로 저장하세요. `astro:env`가 런타임에 읽습니다. |

## 작동 확인하기

`astro dev`에서 보낸 POST에는 `localhost` origin이 담깁니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받습니다. 실제 운영하려면 월 $6부터 500건을 제공하는 Pro를 고르거나, 한 번 결제로 10,000건 $49 또는 50,000건 $149를 구매하세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

1. `astro dev`를 실행하고 `/pricing?utm_source=test&utm_campaign=launch`를 연 뒤 개발자 도구에서 `lead_source` 쿠키를 확인하세요.
2. `/contact-sales`로 가서 **Budget**(예산)을 비우고 JavaScript를 끈 채 제출하세요. 페이지가 예산 오류와 함께 새로고침됩니다.
3. 자격을 갖춘 리드로 제출하세요. 11~50명, $50k 초과, 다음 분기입니다. 이름과 이메일이 채워진 Cal.com으로 이동합니다.
4. **Researching for now**(아직 알아보는 중)로 제출하세요. `/contact-sales/thanks`로 이동합니다.
5. 대시보드에서 **Submissions**를 열고 두 리드의 `utm_source`, `referrer`, `landing_page`, `fit`을 확인하세요.
6. 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에 `email` 필드가 있으면 **Reply**는 제출자에게 갑니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 폼은 POST되지만 페이지에 새로운 것이 표시되지 않음 | 문의 페이지가 사전 렌더링되었습니다. `export const prerender = false`를 추가하세요. Astro 문서는 폼 액션에 요청 시 렌더링을 요구합니다. |
| 오류 후 모든 필드가 비어 있음 | Astro는 폼 제출 시 입력을 지웁니다. 유지하려면 `<ClientRouter />`로 뷰 트랜지션을 켜고 각 입력에 `transition:persist`를 추가하세요. |
| 링크에 태그가 있었는데 UTM 필드가 비어 있음 | 이전 방문에서 쿠키가 이미 있었거나, 랜딩 페이지가 `<FirstTouch />`가 있는 레이아웃을 쓰지 않습니다. 쿠키를 지우고 다시 시도하세요. |
| 빌드 시 `IKIFORM_SECRET_KEY is missing` | `astro:env`가 스키마를 검증합니다. 로컬에서는 `.env`에, 호스트에서는 설정에 변수를 넣으세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |

## 질문

### 정적 Astro 사이트에서 UTM 파라미터를 추적할 수 있나요?

네, 하지만 미들웨어에서는 안 됩니다. 사전 렌더링 페이지의 미들웨어는 빌드 시 실행되기 때문입니다. 레이아웃의 작은 스크립트가 첫 방문에서 태그와 referrer를 읽어 쿠키에 저장하고, 리드가 제출될 때 액션이 쿠키를 읽습니다.

### Astro 폼 액션은 JavaScript 없이도 동작하나요?

네. method가 POST이고 action이 액션으로 설정된 폼은 일반 HTML 방식으로 POST합니다. 페이지는 요청 시 렌더링되어야 하며, Astro.getActionResult()로 결과를 읽습니다.

### Astro Action이 성공한 뒤 어떻게 리디렉션하나요?

핸들러에서 URL을 반환하고, 페이지 frontmatter에서 Astro.getActionResult()를 확인해 오류가 없으면 그 URL로 Astro.redirect()를 반환하세요. Cal.com 링크 같은 절대 URL도 됩니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [리드 수집 폼](https://ikiform.com/ko/guides/lead-capture.md): reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.
- [Next.js 리드 폼](https://ikiform.com/ko/guides/lead-capture/nextjs.md): 서버 액션에서 Zod로 검증하고, 첫 유입 UTM을 쿠키에 보관하고, 자격을 갖춘 리드를 캘린더로 보내는 Next.js App Router 리드 폼입니다.
- [SvelteKit 리드 폼](https://ikiform.com/ko/guides/lead-capture/sveltekit.md): SvelteKit 리드 폼입니다. handle 훅이 첫 유입 UTM을 기록하고, 폼 액션이 Valibot으로 검증하며, redirect()가 자격을 갖춘 리드를 캘린더로 보냅니다.
- [데모 요청 폼](https://ikiform.com/ko/guides/lead-capture/demo-request.md): 리드를 저장한 뒤 이름과 이메일이 미리 채워진 Cal.com이나 Calendly로 방문자를 보내는 데모 예약 폼입니다.
- [견적 요청 폼](https://ikiform.com/ko/guides/lead-capture/quote-request.md): 자료를 업로드 대신 공유 링크로 받고, 모든 요청에 기한을 붙이고, 링크가 많은 리드가 스팸으로 분류되지 않게 하는 견적 요청 폼입니다.
- [상담 신청 폼](https://ikiform.com/ko/guides/lead-capture/consultation-request.md): 회계사, 변호사, 코치, 클리닉을 위한 상담 신청 폼입니다. 연락 방법과 시간을 묻고, 민감한 내용은 통화에서 다룹니다.
