리드 수집 폼6분 분량
Astro로 자격 확인 리드 폼 만들기
Actions로 만든 Astro 리드 폼입니다. Zod 검증, 정적 사이트에서의 첫 유입 UTM, 요청 시 렌더링되는 문의 페이지, 스케줄러로의 리디렉션을 다룹니다.
마케팅 페이지는 사전 렌더링한 채로 두고, 첫 방문의 UTM 태그와 referrer는 클라이언트 스크립트로 작은 쿠키에 기록하세요. 사전 렌더링된 페이지의 미들웨어는 빌드 시에만 실행되기 때문입니다. 문의 페이지는 요청 시 렌더링하고, astro/zod로 Astro Action에서 리드를 검증한 뒤 유입 정보와 함께 폼 백엔드로 전달하세요. 액션에서 다음 URL을 반환하고 Astro.redirect()를 호출해 자격을 갖춘 리드를 스케줄러로 보내세요.
메인 가이드의 Astro 탭은 액션으로 리드를 POST하고 감사 문구를 보여 줍니다. 이 페이지는 이를 영업용 폼으로 바꿉니다. 서버에서 Zod로 확인하는 회사 규모, 예산, 일정, 정적 사이트에서도 남는 캠페인 데이터, 그리고 적합한 리드를 캘린더로 넘기는 흐름입니다.
Astro에서 달라지는 점
Astro는 기본적으로 페이지를 사전 렌더링하며, 이것이 유입 추적의 모든 것을 좌우합니다. 사전 렌더링 페이지의 미들웨어는 빌드 시 한 번, 방문자도 쿠키도 쿼리 문자열도 없이 실행됩니다. ?utm_source=가 실제로 나타나는 랜딩 페이지는 거의 항상 사전 렌더링되므로 미들웨어는 태그를 보지 못합니다.
그래서 첫 유입은 대신 브라우저에서 레이아웃의 짧은 스크립트로 기록하고, 서버에서 액션이 다시 읽습니다. 문의 페이지는 요청 시 렌더링되어야 하는 유일한 페이지입니다. Astro 문서는 폼에서 액션을 호출하는 모든 페이지에 이를 요구합니다.
- 빌드 시 미들웨어는 요청 데이터 없이 사전 렌더링 페이지에 대해 실행되므로 방문을 기록할 수 없습니다.
- 브라우저에서 FirstTouch 스크립트가 첫 페이지 뷰의 UTM 태그와 referrer를 읽습니다.
- 이후 모든 요청에 실리는 lead_source 쿠키에 한 번 저장합니다.
- 문의 폼은 JavaScript 없이 일반 HTML 폼으로 액션에 POST합니다.
- 액션이 Zod로 검증하고, 쿠키를 읽고, 리드를 Ikiform으로 전달합니다.
- 페이지가 결과를 읽어 스케줄러나 감사 페이지로 리디렉션하고, 입력 오류는 해당 필드 아래에 렌더링합니다.
프로젝트 설정
npx astro add vercel(또는 netlify, cloudflare)로 어댑터를 추가한 뒤, astro:env로 키를 선언하세요. 그러면 Astro는 키 없이 빌드하지 않고 브라우저로 절대 보내지 않습니다. 폼과 키 만들기는 Astro 문서 페이지에서 다룹니다.
src/
actions/index.ts
components/FirstTouch.astro
layouts/Base.astro
pages/
pricing.astro
contact-sales.astro
contact-sales/thanks.astro
astro.config.mjs
.envimport { 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 | 서버, 검증 후 |
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.정적 사이트에서 첫 유입 기록하기
<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를 확인하고 건너뛰세요.
액션
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가 되므로, 페이지가 바쁜 폼과 잘못된 입력을 구분할 수 있습니다.
문의 페이지
---
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 적용하기처럼 핸들러에서 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를 구매하세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
astro dev를 실행하고/pricing?utm_source=test&utm_campaign=launch를 연 뒤 개발자 도구에서lead_source쿠키를 확인하세요./contact-sales로 가서 Budget(예산)을 비우고 JavaScript를 끈 채 제출하세요. 페이지가 예산 오류와 함께 새로고침됩니다.- 자격을 갖춘 리드로 제출하세요. 11~50명, $50k 초과, 다음 분기입니다. 이름과 이메일이 채워진 Cal.com으로 이동합니다.
- Researching for now(아직 알아보는 중)로 제출하세요.
/contact-sales/thanks로 이동합니다. - 대시보드에서 Submissions를 열고 두 리드의
utm_source,referrer,landing_page,fit을 확인하세요. - 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에
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와 맞추세요. |
질문
네, 하지만 미들웨어에서는 안 됩니다. 사전 렌더링 페이지의 미들웨어는 빌드 시 실행되기 때문입니다. 레이아웃의 작은 스크립트가 첫 방문에서 태그와 referrer를 읽어 쿠키에 저장하고, 리드가 제출될 때 액션이 쿠키를 읽습니다.
네. method가 POST이고 action이 액션으로 설정된 폼은 일반 HTML 방식으로 POST합니다. 페이지는 요청 시 렌더링되어야 하며, Astro.getActionResult()로 결과를 읽습니다.
핸들러에서 URL을 반환하고, 페이지 frontmatter에서 Astro.getActionResult()를 확인해 오류가 없으면 그 URL로 Astro.redirect()를 반환하세요. Cal.com 링크 같은 절대 URL도 됩니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.