리드 수집 폼6분 분량
React Router로 자격 확인 리드 폼 만들기
React Router 프레임워크 모드 리드 폼입니다. 서명된 첫 유입 쿠키를 쓰는 루트 미들웨어, 검증하는 라우트 액션, 스케줄러로의 리디렉션으로 구성합니다.
첫 문서 요청에서 루트 미들웨어로 UTM 태그와 referrer를 createCookie의 서명된 쿠키에 기록하세요. 라우트의 action에서 리드를 검증하고 유입 정보와 함께 폼 백엔드로 전달한 뒤, 자격을 갖춘 리드에는 스케줄러로의 redirect()를, 나머지에는 필드 오류가 담긴 data()를 반환하세요. <Form>은 JavaScript 유무와 관계없이 POST하므로 같은 액션이 양쪽을 처리합니다.
메인 가이드의 React Router 탭은 라우트 액션에서 리드를 전달하고 감사 문구를 렌더링합니다. 이 페이지는 프레임워크 모드에서 영업용 버전을 만듭니다. 서버에서 확인하는 자격 확인 질문 세 개, 방문자를 데려온 첫 캠페인, 그리고 적합한 리드를 캘린더로 보내는 리디렉션입니다.
React Router에서 달라지는 점
React Router의 데이터 API는 일반 HTML 폼을 중심으로 설계되었습니다. <Form>은 JavaScript 유무와 관계없이 같은 방식으로 라우트의 action에 POST하며, 액션이 반환하는 것이 오류가 담긴 data()든 redirect()든 양쪽에서 동작합니다. JavaScript가 있으면 React Router는 다른 origin으로의 리디렉션을 전체 문서 로드로 따라갑니다. 서버 경로는 한 번만 작성하면 됩니다.
이 일에 맞는 요소가 두 개 더 있습니다. React Router 8부터 기본으로 켜진 미들웨어는 모든 문서 요청에서 실행되므로 루트 라우트가 첫 유입을 기록할 수 있습니다. 그리고 createCookie는 시크릿으로 값에 서명하므로, 방문자가 CRM에 닿기 전에 자신의 유입 정보를 다시 쓸 수 없습니다.
- 첫 문서 요청은 URL에 캠페인 태그를, 헤더에 외부 referrer를 담고 있습니다.
- 루트 미들웨어가 페이지를 렌더링하게 한 뒤, 출처가 담긴 서명된 Set-Cookie를 덧붙입니다.
- JavaScript가 있으면 Form이 데이터 요청으로 액션에 POST하고, React Router가 전체 페이지 로드로 리디렉션을 따라갑니다.
- JavaScript가 없으면 브라우저가 기본 방식으로 POST하고 303을 직접 따라갑니다.
- 어느 쪽이든 검증에 실패하면 상태 400과 함께 데이터를 반환하고, 페이지가 입력한 값 옆에 오류를 렌더링합니다.
프로젝트 설정
React Router 8의 프레임워크 모드 앱에서 시작하세요. 서버 전용 변수 두 개를 추가하세요. Ikiform 시크릿 키와 쿠키 서명용 시크릿입니다. 키 만들기는 서버에서 POST하기에서 다룹니다.
app/
root.tsx
lead-source.server.ts
routes.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
react-router.config.ts
.envIKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-string그런 다음 폼의 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는 서버 전용 환경 변수에 두세요.서명된 첫 유입 쿠키
import { createCookie } from "react-router";
export interface LeadSource {
utm_source: string;
utm_medium: string;
utm_campaign: string;
referrer: string;
landing_page: string;
}
export const leadSource = createCookie("lead_source", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
secrets: [process.env.COOKIE_SECRET ?? ""],
});
export async function firstTouch(request: Request): Promise<LeadSource | null> {
const url = new URL(request.url);
const referer = request.headers.get("referer") ?? "";
const from = URL.canParse(referer) ? new URL(referer) : null;
const tag = (key: string) => url.searchParams.get(key)?.slice(0, 100) ?? "";
const source: LeadSource = {
utm_source: tag("utm_source"),
utm_medium: tag("utm_medium"),
utm_campaign: tag("utm_campaign"),
referrer: from && from.host !== url.host ? from.href.slice(0, 300) : "",
landing_page: url.pathname.slice(0, 200),
};
const hasSignal = source.referrer || source.utm_source || source.utm_medium || source.utm_campaign;
return hasSignal ? source : null;
}import { leadSource, firstTouch } from "./lead-source.server";
import type { Route } from "./+types/root";
const recordFirstTouch: Route.MiddlewareFunction = async ({ request }, next) => {
const response = await next();
if (request.method !== "GET") return response;
if (await leadSource.parse(request.headers.get("Cookie"))) return response;
const source = await firstTouch(request);
if (source) response.headers.append("Set-Cookie", await leadSource.serialize(source));
return response;
};
export const middleware: Route.MiddlewareFunction[] = [recordFirstTouch];.server.ts접미사가 모듈과COOKIE_SECRET을 클라이언트 번들에서 제외합니다.leadSource.parse는 쿠키가 없거나 서명이 맞지 않으면null을 반환하므로, 변조된 값은 신뢰되지 않고 무시됩니다.- 미들웨어는 먼저
next()를 호출한 뒤 헤더만 추가합니다. 문서에 따르면 서버 응답의 본문은 건드리지 말고 상태를 읽고 헤더를 설정해야 합니다. - 서버 미들웨어는 클라이언트 측 내비게이션에서는
.data를 가져올 때만 실행됩니다. 괜찮습니다. 태그는 첫 문서 요청에서 도착합니다.
라우트 액션
import { data, Form, redirect, useNavigation } from "react-router";
import { type LeadSource, leadSource } from "../lead-source.server";
import type { Route } from "./+types/contact-sales";
const CHOICES = {
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"],
};
function validate(values: Record<string, string>) {
const errors: Record<string, string> = {};
if (!values.name) errors.name = "Tell us who to reply to.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = "Enter a work email we can reply to.";
if (!values.company) errors.company = "Which company is this for?";
for (const [key, allowed] of Object.entries(CHOICES)) {
if (!allowed.includes(values[key])) errors[key] = "Pick one of the options.";
}
if (values.message.length > 5000) errors.message = "Keep it under 5,000 characters.";
return errors;
}
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const keys = ["name", "email", "company", "company_size", "budget", "timeline", "message", "_gotcha"];
const values = Object.fromEntries(keys.map((key) => [key, String(form.get(key) ?? "").trim()]));
const errors = validate(values);
if (Object.keys(errors).length) return data({ errors, values }, { status: 400 });
const source: Partial<LeadSource> = (await leadSource.parse(request.headers.get("Cookie"))) ?? {};
const qualified =
values.company_size !== "1_10" &&
["10k_50k", "50k_plus"].includes(values.budget) &&
["this_quarter", "next_quarter"].includes(values.timeline);
const origin = 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 ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...source, ...values, fit: qualified ? "qualified" : "nurture" }),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const failed = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
if (!Object.keys(failed).length) failed.form = "We couldn't send that. Email sales@example.com instead.";
return data({ errors: failed, values }, { status: response.status });
}
if (!qualified || values._gotcha) return redirect("/contact-sales/thanks", 303);
return redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name: values.name, email: values.email })}`, 303);
}
export default function ContactSales({ actionData }: Route.ComponentProps) {
const pending = useNavigation().state === "submitting";
const errors: Record<string, string | undefined> = actionData?.errors ?? {};
const values: Record<string, string | undefined> = actionData?.values ?? {};
const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
{error("name")}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
{error("email")}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
{error("company")}
<label htmlFor="company_size">Company size</label>
<select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""}>
<option value="" disabled>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>
{error("company_size")}
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue={values.budget ?? ""}>
<option value="" disabled>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>
{error("budget")}
<label htmlFor="timeline">When do you want to start?</label>
<select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""}>
<option value="" disabled>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>
{error("timeline")}
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} defaultValue={values.message} />
{error("message")}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error("form")}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</Form>
);
}- 검증은 의존성 없는 작은 함수입니다. 이미 Zod를 쓴다면 바꿔 넣으세요. 액션의 형태는 같습니다.
data({ errors, values }, { status: 400 })는 실제 오류 상태를 보내므로, POST가 실패했을 때 React Router가 로더를 재검증하지 않게도 합니다.- 브라우저의
Origin헤더를 전달합니다. 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이 헤더로 무료localhost테스트를 판별하기 때문입니다. redirect()는 절대 URL을 받습니다. 문서는 리디렉션 대상의 사용자 입력을 검증하라고 경고합니다. 여기서는 쿼리 문자열만 방문자에게서 오고URLSearchParams가 인코딩합니다.values는source뒤에 펼쳐지지만 모든 키가 다르므로 쿠키가 폼 필드를 덮어쓸 수 없습니다.
처리 중 UI와 오류 UI
액션이 실행되는 동안 useNavigation().state는 submitting이며 버튼을 비활성화합니다. 입력은 actionData.values에서 defaultValue를 읽으므로, 오류와 함께 돌아온 기본 POST에서도 방문자가 입력한 모든 것이 표시됩니다. JavaScript가 있으면 폼은 아예 초기화되지 않습니다.
허니팟과 사람 확인
허니팟은 다른 값과 함께 전달되고, Ikiform은 값이 채워진 것을 스팸으로 저장하며, 액션은 이를 감사 라우트로 보냅니다. 시크릿 키는 Ikiform의 사람 확인을 건너뛰므로, 필요하다면 폼에 Turnstile 적용하기처럼 액션에서 Turnstile 토큰을 검증하세요.
렌더링과 배포
액션에는 런타임 서버가 필요합니다. react-router.config.ts에 ssr: false가 있으면 빌드가 action이 있는 라우트를 모두 거부합니다. prerender에 나열한 경로는 정적 파일이 되고, 그 파일을 제공하는 호스트는 미들웨어를 실행하지 않으므로 캠페인 랜딩 페이지는 서버 렌더링으로 두세요.
| 호스트 | 알아 둘 점 |
|---|---|
| Vercel | react-router.config.ts에 @vercel/react-router 프리셋을 추가하세요. 프로젝트의 환경 변수에 두 시크릿을 설정하세요. |
| Netlify | @netlify/vite-plugin-react-router를 추가하세요. 사이트 설정에서 두 시크릿을 설정하세요. |
| Cloudflare | @cloudflare/vite-plugin을 쓰세요. React Router 8은 자체 Cloudflare 개발 프록시를 제거했습니다. wrangler secret put으로 두 시크릿을 저장하고, Worker 템플릿이 env를 노출하는 방식으로 읽으세요. |
작동 확인하기
개발 서버에서 보낸 POST에는 localhost origin이 담깁니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 localhost에서 월 50건의 테스트 제출을 받습니다. 실제 운영하려면 월 $6부터 500건을 제공하는 Pro를 고르거나, 한 번 결제로 10,000건 $49 또는 50,000건 $149를 구매하세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
react-router dev를 실행하고/?utm_source=test&utm_campaign=launch를 연 뒤 개발자 도구에서 서명된lead_source쿠키를 찾으세요.- 쿠키 값을 손으로 고치고 리드를 제출하세요. 서명이 더 이상 맞지 않으므로 UTM 필드가 비어서 도착합니다.
- JavaScript를 끄고 Timeline(일정)을 비운 채 제출하세요. 페이지가 오류와 입력값과 함께 새로고침됩니다.
- 자격을 갖춘 리드로 제출하세요. 51~200명, $50k 초과, 다음 분기입니다. 이름과 이메일이 채워진 Cal.com으로 이동합니다.
- 대시보드에서 Submissions를 열고
utm_campaign,referrer,fit을 확인하세요. - 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에
email필드가 있으면 Reply는 제출자에게 갑니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
middleware가 효과가 없음 | React Router 7에서는 future.v8_middleware 플래그 뒤에 있었습니다. 플래그를 켜거나, 기본값인 8로 업그레이드하세요. |
action 함수가 금지되었다며 빌드가 실패함 | ssr: false가 설정되어 있습니다. 앱의 SSR을 다시 켜세요. 특정 경로의 사전 렌더링은 함께 쓸 수 있습니다. |
| 쿠키가 유지되지 않음 | COOKIE_SECRET이 비어 있거나 배포할 때마다 바뀌어서 모든 서명이 실패합니다. 고정된 값을 설정하세요. |
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요. |
402 test_limit_reached | 워크스페이스에 플랜이 없고 이번 달 무료 localhost 테스트 50건을 다 썼습니다. 플랜을 고르거나 대시보드의 Send test를 사용하세요. |
질문
네. redirect()는 절대 URL을 받습니다. 기본 폼 POST는 Location 헤더를 직접 따라가고, JavaScript가 있으면 React Router가 다른 origin으로 전체 문서 내비게이션을 합니다.
next()를 호출해 응답을 받은 뒤, createCookie의 cookie.serialize()로 만든 Set-Cookie 헤더를 덧붙이세요. 미들웨어는 root.tsx 같은 라우트에서 Route.MiddlewareFunction[]으로 export하세요.
네. Form은 일반 form 요소를 렌더링하고, 액션은 기본 POST에서 실행되며, 오류가 담긴 data()나 redirect()를 반환하는 것은 JavaScript가 있을 때와 똑같이 동작합니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.