새 SaaS용 대기자 명단5분 분량
React Router로 대기자 명단 만들기
모든 페이지 푸터에 있는 React Router 대기자 명단: fetcher가 라우트 액션 하나로 POST하고, 가입마다 페이지를 태그하며, JavaScript 없이도 작동합니다.
대기자 명단에 전용 라우트를 주고, 이메일을 검증해 시크릿 키로 폼 백엔드에 전달하는 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>이므로, 그 라우트에는 결과를 보여 줄 자체 페이지가 필요합니다.
- 레이아웃이 모든 페이지에 대기자 명단 폼을 렌더링하고, 페이지 경로로 만든 숨겨진 ref를 넣습니다.
- JavaScript가 있으면 fetcher.Form이 페이지를 떠나지 않고 /waitlist로 POST합니다. fetcher의 key가 공유되므로 헤더의 안내 문구도 같은 결과를 봅니다.
- JavaScript가 없으면 같은 폼이 일반 내비게이션으로 /waitlist에 POST합니다.
- 어느 쪽이든 /waitlist 액션이 이메일을 검사하고 시크릿 키로 가입을 Ikiform에 전달합니다.
- 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은 서버에서만 실행되므로 키가 클라이언트로 나가지 않습니다. 절대 컴포넌트에서 가져오지 마세요.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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 } }
]
}만들기
라우트 등록하기
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;대기자 명단 라우트
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가 없을 때의 폴백입니다. 액션 결과와 함께 같은 폼을 렌더링합니다.
공유 폼
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가 느리거나 비용이 드는 라우트에는 이것을 추가하세요.
import type { ShouldRevalidateFunctionArgs } from "react-router";
export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
if (formAction === "/waitlist") return false;
return defaultShouldRevalidate;
}렌더링과 배포
마케팅 페이지는 방문자마다 달라지지 않으므로 프리렌더링하고 서버는 액션용으로 남겨 두세요.
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는 여전히 서버에서 실행됩니다.
| 호스트 | 추가할 것 | 키 읽기 |
|---|---|---|
| Vercel | react-router.config.ts에 presets: [vercelPreset()]를 지정한 @vercel/react-router | process.env, Environment Variables에서 설정 |
| Netlify | Vite 플러그인에 netlifyReactRouter()로 넣는 @netlify/vite-plugin-react-router | process.env, Environment variables에서 설정 |
| Cloudflare Workers | reactRouter() 앞에 cloudflare()로 넣는 @cloudflare/vite-plugin | cloudflare: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 패턴은 더 이상 통하지 않습니다. 액션의 나머지는 그대로입니다.
작동 확인하기
react-router dev를 실행하고http://localhost:5173/pricing을 열어 푸터에서 가입하세요. 감사 문구가 폼을 대신하고 헤더 안내 문구도 함께 바뀝니다.- 대시보드에서 Submissions를 여세요. 가입의
ref가pricing으로 설정되어 있습니다. - JavaScript를 끄고 홈 페이지에서 가입하세요. 감사 문구가 있는
/waitlist로 이동하고, 가입의ref는home으로 설정됩니다. - 푸터에서
a@b를 제출하세요. 입력한 주소가 그대로 남은 채 입력 아래에 오류가 표시됩니다. - 허니팟을 채워서
_gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. 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에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |
질문
useFetcher를 쓰고, 처리할 라우트의 액션으로 fetcher.Form을 렌더링하세요. 방문자는 페이지에 머물고 결과는 fetcher.data로 옵니다. 하이드레이션 전에는 일반 폼으로 POST합니다.
ssr이 true이면 작동합니다. 프리렌더링된 페이지는 HTML로 제공되고, 그 폼은 서버에서 실행되는 액션으로 POST합니다. ssr을 false로 설정하면 액션을 쓸 수 없습니다.
위의 sourceFor처럼 페이지 경로를 숨겨진 ref 입력에 넣으세요. 경로는 서버와 클라이언트 모두에서 알 수 있으므로 프리렌더링된 페이지와 하이드레이션 전에도 작동합니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.