리드 수집 폼

리드 수집 폼6분 분량

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

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

Preet SutharIkiform 창업자

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

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

Astro에서 달라지는 점

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

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

Astro가 리드 폼의 각 부분을 실행하는 위치유입 정보제출미들웨어한 번 실행, 방문자 없음FirstTouch스크립트?utm_과 referrer읽기lead_source쿠키POST와 함께 전송폼 POSTaction={actions.submitLead}Astro ActionZod, 쿠키 읽기Astro.redirectCal.com 또는/thanks같은 페이지각 필드 아래 메시지설정폼 데이터result.data.nextisInputError빌드 시브라우저에서온디맨드Astro가 리드 폼의 각 부분을 실행하는 위치유입 정보제출미들웨어한 번 실행, 방문자 없음FirstTouch 스크립트?utm_과 referrer 읽기lead_source 쿠키POST와 함께 전송폼 POSTaction={actions.submitLead}Astro ActionZod, 쿠키 읽기Astro.redirectCal.com 또는 /thanks같은 페이지각 필드 아래 메시지설정폼 데이터result.data.nextisInputError
  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 문서 페이지에서 다룹니다.

파일
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
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이 같은 규칙을 확인하고 나머지는 거부합니다.

필드 IDIkiform의 타입설정 주체
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방문자
messageTextarea, 선택방문자
utm_source, utm_medium, utm_campaign, referrer, landing_page텍스트, 선택서버, 첫 방문에서
fit선택형: qualified, nurture서버, 검증 후
서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

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

src/components/FirstTouch.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
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
---
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를 구매하세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.

  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 missingastro:env가 스키마를 검증합니다. 로컬에서는 .env에, 호스트에서는 설정에 변수를 넣으세요.
422 validation_failedPOST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요.

질문

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