새 SaaS용 대기자 명단

새 SaaS용 대기자 명단5분 분량

React Router로 대기자 명단 만들기

모든 페이지 푸터에 있는 React Router 대기자 명단: fetcher가 라우트 액션 하나로 POST하고, 가입마다 페이지를 태그하며, JavaScript 없이도 작동합니다.

Preet SutharIkiform 창업자

대기자 명단에 전용 라우트를 주고, 이메일을 검증해 시크릿 키로 폼 백엔드에 전달하는 action을 두세요. 레이아웃에서 useFetcher로 폼을 렌더링하면 어느 페이지에서 가입하든 그 페이지에 머물고 경로가 태그됩니다. JavaScript가 없으면 같은 폼이 대기자 명단 라우트로 POST하고 그 페이지가 결과를 보여 줍니다.

메인 가이드에는 홈 라우트에 폼과 action을 둔 React Router 탭이 있습니다. 한 페이지짜리 사이트에는 그걸로 충분합니다. 출시 전 SaaS 사이트는 대개 요금제, 블로그 글, 홈 페이지처럼 페이지가 몇 개 있고, 모든 페이지에 가입 폼을 두고 어느 페이지가 효과적이었는지 알고 싶어집니다. 이 페이지는 폼을 레이아웃에 두고 모든 사본을 액션 하나로 POST합니다.

React Router에서 다른 점

프레임워크 모드에서 액션은 라우트에 속하고, <Form>은 자신이 놓인 라우트로 POST합니다. 레이아웃의 폼은 모든 라우트에 놓이므로, 어떤 컴포넌트를 고르느냐에 따라 제출 시 일어나는 일이 달라집니다.

Item<Form method="post"><fetcher.Form action="/waitlist">
POST하는 곳action을 지정하지 않으면 현재 라우트/waitlist 라우트의 액션
제출 후내비게이션. URL과 히스토리가 바뀜내비게이션 없음. 방문자가 페이지에 머묾
결과실행된 라우트의 actionData같은 key를 쓰는 모든 fetcher가 공유하는 fetcher.data
JavaScript 없음일반 POST자체 페이지를 렌더링하는 /waitlist로 일반 POST

푸터 가입에는 fetcher가 알맞습니다. 목록에 가입하려고 요금제 페이지를 떠나고 싶은 사람은 없습니다. 기능이 줄어들 때도 깔끔합니다. 하이드레이션 전에는 fetcher.Form이 /waitlist로 POST하는 일반 <form>이므로, 그 라우트에는 결과를 보여 줄 자체 페이지가 필요합니다.

모든 React Router 페이지의 푸터 가입이 라우트 액션 하나를 거침/pricingref = pricingfetcher.Formkey: waitlist/waitlist 액션Ikiform으로 전달fetcher.data같은 페이지, 헤더 갱신/blog/launchref =blog-launch일반 폼JavaScript 로드안 됨/waitlist 액션같은 코드/waitlist페이지actionDataPOSTPOST페이지폼액션결과모든 React Router 페이지의 푸터 가입이 라우트 액션 하나를 거침/pricingref = pricingfetcher.Formkey: waitlist/waitlist 액션Ikiform으로 전달fetcher.data같은 페이지, 헤더 갱신/blog/launchref = blog-launch일반 폼JavaScript 로드 안 됨/waitlist 액션같은 코드/waitlist 페이지actionDataPOSTPOST
  1. 레이아웃이 모든 페이지에 대기자 명단 폼을 렌더링하고, 페이지 경로로 만든 숨겨진 ref를 넣습니다.
  2. JavaScript가 있으면 fetcher.Form이 페이지를 떠나지 않고 /waitlist로 POST합니다. fetcher의 key가 공유되므로 헤더의 안내 문구도 같은 결과를 봅니다.
  3. JavaScript가 없으면 같은 폼이 일반 내비게이션으로 /waitlist에 POST합니다.
  4. 어느 쪽이든 /waitlist 액션이 이메일을 검사하고 시크릿 키로 가입을 Ikiform에 전달합니다.
  5. fetcher는 fetcher.data에서 결과를 읽습니다. 일반 POST는 /waitlist 페이지로 이동하고, 그 페이지가 actionData로 결과를 보여 줍니다.
액션 하나, 폼은 몇 개든. 경로를 보면 어느 페이지가 가입을 이끌어 냈는지 알 수 있습니다.

프로젝트 설정하기

폼과 시크릿 키 만들기는 서버 가이드에서 다룹니다. 이 페이지의 파일은 다음과 같습니다.

프로젝트
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env

키는 .env에 두고 액션에서 process.env로 읽으세요. 라우트 모듈은 브라우저용으로 분리되지만 action은 서버에서만 실행되므로 키가 클라이언트로 나가지 않습니다. 절대 컴포넌트에서 가져오지 마세요.

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

액션은 email과 ref를 보냅니다. 폼의 Fields 탭에 필드 정의가 있다면 둘 다 넣어야 합니다.

필드
{
  "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 } }
  ]
}

만들기

라우트 등록하기

app/routes.ts
import { index, type RouteConfig, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("pricing", "routes/pricing.tsx"),
  route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;

대기자 명단 라우트

app/routes/waitlist.tsx
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) {
    return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
  }
  const ref = String(form.get("ref") ?? "");

  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: request.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
  return data(
    {
      ok: false,
      message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      email,
    },
    { status: response?.status === 429 ? 429 : 400 },
  );
}

export default function Waitlist({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Join the Acme waitlist</h1>
      <WaitlistForm initial={actionData} />
    </main>
  );
}
  • data()는 오류를 던지는 응답으로 바꾸지 않고 상태 코드를 설정하므로, 컴포넌트가 여전히 메시지를 렌더링합니다.
  • 모든 응답이 이메일을 함께 돌려주므로 오류 후에도 입력란에 다시 표시할 수 있습니다.
  • Ikiform은 브라우저의 Origin 헤더로 무료 localhost 테스트 제출을 세는데 액션 자체의 fetch는 이 헤더를 보내지 않으므로, 이를 전달합니다.
  • ref는 정의와 같은 max_length로 영문 소문자, 숫자, -, _로 된 40자 이하여야 합니다. 그 밖의 값은 direct로 저장됩니다.
  • 기본 내보내기는 JavaScript가 없을 때의 폴백입니다. 액션 결과와 함께 같은 폼을 렌더링합니다.

공유 폼

app/components/waitlist-form.tsx
import { useFetcher, useLocation } from "react-router";

interface Outcome {
  ok: boolean;
  message: string;
  email: string;
}

export function sourceFor(pathname: string) {
  const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
  return (slug || "home").slice(0, 40);
}

export function WaitlistForm({ initial }: { initial?: Outcome }) {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  const { pathname } = useLocation();
  const outcome = fetcher.data ?? initial;
  const pending = fetcher.state !== "idle";

  if (outcome?.ok) return <p role="status">{outcome.message}</p>;

  return (
    <fetcher.Form method="post" action="/waitlist">
      <label>
        Email
        <input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
      </label>
      <input type="hidden" name="ref" value={sourceFor(pathname)} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{outcome?.message}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </fetcher.Form>
  );
}

export function JoinedNote() {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}
  • key: "waitlist"는 그 key를 쓰는 모든 fetcher를 하나의 공유 fetcher로 만듭니다. 헤더의 JoinedNote가 같은 fetcher.data를 읽으므로, 푸터 폼이 성공하는 순간 바뀝니다.
  • sourceFor는 /blog/launch를 blog-launch로, /를 home으로 바꿉니다. 경로는 서버에서 알 수 있으므로 프리렌더링된 페이지에서도 작동합니다.
  • fetcher.state는 POST가 실행되는 동안 submitting, React Router가 재검증하는 동안 loading이므로, 둘 다 끝날 때까지 버튼이 비활성화됩니다.
  • 라벨이 입력을 감싸므로 한 페이지의 히어로와 푸터 사본이 id를 공유하지 않습니다.

app/root.tsx의 Layout에서 id="waitlist"인 푸터 안에 <WaitlistForm />을, 헤더에 <JoinedNote />를 렌더링하세요. 허니팟은 함께 실려 손대지 않은 채 Ikiform에 도달합니다. 시크릿 키를 쓴 POST는 Ikiform의 사람 확인을 건너뛰므로 이 폼에서는 Spam check를 켜도 아무 효과가 없습니다.

모든 loader의 재로드 건너뛰기

액션이 끝나면 React Router는 페이지에 있는 모든 라우트의 데이터를 다시 불러옵니다. 가입은 그중 아무것도 바꾸지 않습니다. loader가 느리거나 비용이 드는 라우트에는 이것을 추가하세요.

app/routes/pricing.tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}

렌더링과 배포

마케팅 페이지는 방문자마다 달라지지 않으므로 프리렌더링하고 서버는 액션용으로 남겨 두세요.

react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: ["/", "/pricing"],
} satisfies Config;

ssr: true를 유지하세요. ssr: false이면 런타임 서버가 없고, React Router는 action을 내보내는 라우트가 있으면 빌드를 실패시킵니다. ssr: true이면 /와 /pricing은 빌드 시점에 HTML로 작성되고 /waitlist는 여전히 서버에서 실행됩니다.

호스트추가할 것키 읽기
Vercelreact-router.config.ts에 presets: [vercelPreset()]를 지정한 @vercel/react-routerprocess.env, Environment Variables에서 설정
NetlifyVite 플러그인에 netlifyReactRouter()로 넣는 @netlify/vite-plugin-react-routerprocess.env, Environment variables에서 설정
Cloudflare WorkersreactRouter() 앞에 cloudflare()로 넣는 @cloudflare/vite-plugincloudflare:workers의 env.IKIFORM_SECRET_KEY, wrangler secret put으로 설정

Cloudflare에서는 import { env } from "cloudflare:workers"로 가져와 process.env 대신 거기서 키를 읽으세요. React Router 8에서는 액션의 context가 RouterContextProvider이므로 v7의 context.cloudflare.env 패턴은 더 이상 통하지 않습니다. 액션의 나머지는 그대로입니다.

작동 확인하기

  1. react-router dev를 실행하고 http://localhost:5173/pricing을 열어 푸터에서 가입하세요. 감사 문구가 폼을 대신하고 헤더 안내 문구도 함께 바뀝니다.
  2. 대시보드에서 Submissions를 여세요. 가입의 ref가 pricing으로 설정되어 있습니다.
  3. JavaScript를 끄고 홈 페이지에서 가입하세요. 감사 문구가 있는 /waitlist로 이동하고, 가입의 ref는 home으로 설정됩니다.
  4. 푸터에서 a@b를 제출하세요. 입력한 주소가 그대로 남은 채 입력 아래에 오류가 표시됩니다.
  5. 허니팟을 채워서 _gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
  6. react-router dev에서 보낸 POST는 localhost 출처를 가지므로 테스트 제출로 집계됩니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도 localhost에서 보낸 테스트 제출을 매달 50건 받습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 내세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.

주의할 점

문제해결 방법
JavaScript가 켜져 있는데도 푸터 가입이 /waitlist로 이동함폼이 fetcher.Form이 아니라 <Form>이나 일반 <form>입니다. fetcher를 쓰고, 액션에서 redirect()를 반환하지 마세요. fetcher 액션의 리디렉션은 내비게이션을 일으킵니다.
action 내보내기에 관한 오류로 빌드가 실패함react-router.config.ts에서 ssr가 false입니다. true로 설정하지 않으면 대기자 명단을 실행할 서버가 없습니다.
모든 가입의 ref가 waitlist임POST가 /waitlist 페이지 자체에서 옵니다. 그건 폴백 페이지이니, 링크는 실제 페이지를 가리켜야 합니다.
unknown_field와 함께 422필드 정의에 ref가 없습니다. 위의 정의처럼 추가하세요.
402 plan_required워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. Settings › Plan and usage에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다.

질문

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