새 SaaS용 대기자 명단5분 분량
TanStack Start로 대기자 명단 만들기
TanStack Start 대기자 명단: 서버 라우트 하나가 일반 폼 POST와 fetch에 모두 응답하고, validateSearch가 유입 경로와 결과를 URL로 전달합니다.
/api/waitlist에 서버 라우트를 추가해 이메일을 검증하고, 시크릿 키로 가입을 폼 백엔드에 전달하고, fetch에는 JSON으로, 일반 폼 POST에는 303 리디렉션으로 응답하게 하세요. 랜딩 라우트는 validateSearch로 ?ref=, ?joined=, ?problem=을 읽으므로, JavaScript가 있든 없든 유입 경로 태그와 결과가 유지됩니다.
메인 가이드에는 createServerFn과 useServerFn으로 만든 TanStack Start 탭이 있습니다. 짧지만, 폼은 React가 하이드레이션된 뒤에만 작동하고 가입마다 유입 경로가 고정 문자열입니다. 이 페이지는 출시일에 공유할 버전을 만듭니다. 하이드레이션 전에도 POST하고, 들어온 링크로 가입마다 태그를 달고, 결과를 URL에 유지합니다.
TanStack Start에서 다른 점
Start에서는 서버에서 코드를 실행하는 방법이 두 가지이며, 폼에서는 서로 다르게 동작합니다.
| Item | 서버 함수 | 서버 라우트 |
|---|---|---|
| 정의 방법 | createServerFn().handler() | server.handlers를 넣은 createFileRoute() |
| 호출하는 쪽 | 내 컴포넌트, RPC 호출로 | 일반 <form>을 포함한 모든 HTTP 클라이언트 |
| 응답 | 호출자에 맞게 직렬화 | 직접 만드는 Response |
| JavaScript 없음 | .url을 통해 가능하지만 응답은 제어할 수 없음 | JSON이나 페이지로 돌아가는 리디렉션 중 선택 |
대기자 명단 POST는 두 종류의 클라이언트에서 옵니다. 스크립트를 실행하는 브라우저와 아직 스크립트를 로드하지 않은 브라우저입니다. 서버 라우트는 핸들러 하나로 각각이 기대하는 방식으로 응답할 수 있습니다. 둘은 Accept 헤더로 구분합니다. 스크립트는 application/json을 요청하고, 일반 폼 POST는 그렇지 않습니다.
- 랜딩 라우트가 validateSearch로 ?ref=를 읽어 숨겨진 입력에 렌더링합니다.
- 폼이 FormData를 서버 라우트로 POST합니다. 스크립트가 로드되었으면 fetch로, 아니면 일반 POST로 보냅니다.
- 라우트가 이메일과 유입 경로 태그를 검사한 뒤 시크릿 키로 가입을 Ikiform에 전달합니다.
- fetch POST는 JSON을 요청했으므로 { ok }나 메시지를 받고, 컴포넌트가 감사 문구나 오류로 바꿉니다.
- 일반 POST는 ?joined=yes나 ?problem= 코드, 그리고 같은 ref와 함께 랜딩 페이지로 돌아가는 303을 받습니다. validateSearch가 이를 읽고 페이지가 결과를 렌더링합니다.
프로젝트 설정하기
먼저 폼과 시크릿 키를 만드세요. 서버 가이드에서 둘 다 다룹니다. 수정할 파일은 다음과 같습니다.
src/
routes/
__root.tsx
index.tsx
api/waitlist.ts
waitlist-messages.ts
vite.config.ts
.env.local키는 .env.local에 저장하세요. Start가 개발 중에 이 파일을 불러오고, git에는 들어가지 않습니다. 서버 코드는 process.env에서 읽습니다. 절대 VITE_ 접두사를 붙이지 마세요. 그러면 브라우저 코드에 번들링됩니다.
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 } }
]
}만들기
오류 문구가 아니라 오류 코드
export type Problem = "email" | "busy" | "failed";
export const MESSAGES: Record<Problem, string> = {
email: "Enter the email we should send your invite to.",
busy: "Lots of people are joining. Try again in a minute.",
failed: "Couldn't join the list. Try again.",
};
export function isProblem(value: unknown): value is Problem {
return typeof value === "string" && value in MESSAGES;
}리디렉션은 메시지 자체가 아니라 짧은 코드를 URL에 넣습니다. 그러면 ?problem=이 있는 링크는 여러분이 쓴 세 문장 중 하나만 보여 줄 수 있고, 낯선 사람이 URL에 입력한 아무 문구나 보여 줄 수 없습니다.
서버 라우트
import { createFileRoute } from "@tanstack/react-router";
import { MESSAGES, type Problem } from "../../waitlist-messages";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;
async function forward(request: Request, form: FormData): Promise<Problem | null> {
const email = String(form.get("email") ?? "").trim();
if (!EMAIL.test(email)) return "email";
const ref = String(form.get("ref") ?? "");
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({
email,
ref: SOURCE.test(ref) ? ref : "direct",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
if (!response) return "failed";
if (response.status === 429) return "busy";
const result = await response.json().catch(() => null);
if (result?.ok) return null;
return result?.error?.fields?.email ? "email" : "failed";
}
export const Route = createFileRoute("/api/waitlist")({
server: {
handlers: {
POST: async ({ request }) => {
const form = await request.formData();
const problem = await forward(request, form);
if (request.headers.get("accept")?.includes("application/json")) {
return problem
? Response.json({ ok: false, message: MESSAGES[problem] }, { status: problem === "busy" ? 429 : 400 })
: Response.json({ ok: true });
}
const back = new URL("/", request.url);
back.searchParams.set("ref", String(form.get("ref") ?? ""));
if (problem) back.searchParams.set("problem", problem);
else back.searchParams.set("joined", "yes");
return Response.redirect(back, 303);
},
},
},
});request.formData()는 두 인코딩을 모두 읽습니다.fetch가FormData본문으로 보내는multipart/form-data와 일반 POST의 urlencoded 본문입니다.process.env는 핸들러 안에서 요청 시점에 읽습니다. Cloudflare Workers에서는 아래wrangler.jsonc처럼nodejs_compat가 켜져 있고 호환성 날짜가 2025-04-01 이후일 때만 시크릿이 들어 있습니다.- Ikiform은 브라우저의
Origin헤더로 무료localhost테스트 제출을 세는데 라우트 자체의fetch는 이 헤더를 보내지 않으므로, 이를 전달합니다. ref는 정의의max_length에 맞춰 영문 소문자, 숫자,-,_로 된 40자 이하여야 합니다. 그 밖의 값은direct로 저장됩니다.Response.redirect(back, 303)은 브라우저가 GET으로 랜딩 페이지를 불러오게 하므로, 새로 고침해도 가입이 두 번 POST되지 않습니다.
랜딩 라우트
import { createFileRoute } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
import { isProblem, MESSAGES, type Problem } from "../waitlist-messages";
interface Search {
ref?: string;
joined?: boolean;
problem?: Problem;
}
export const Route = createFileRoute("/")({
validateSearch: (search: Record<string, unknown>): Search => ({
ref: typeof search.ref === "string" ? search.ref.toLowerCase().slice(0, 40) : undefined,
joined: search.joined === "yes" ? true : undefined,
problem: isProblem(search.problem) ? search.problem : undefined,
}),
component: Home,
});
function Home() {
const search = Route.useSearch();
const [joined, setJoined] = useState(Boolean(search.joined));
const [error, setError] = useState(search.problem ? MESSAGES[search.problem] : "");
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setError("");
try {
const response = await fetch("/api/waitlist", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget),
});
const outcome = await response.json();
if (outcome.ok) setJoined(true);
else setError(outcome.message);
} catch {
setError("Couldn't reach the server. Try again.");
} finally {
setPending(false);
}
}
if (joined) return <p role="status">You're on the list. We'll email you on launch day.</p>;
return (
<form method="post" action="/api/waitlist" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value={search.ref ?? "direct"} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<p role="alert">{error}</p>
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}validateSearch는 원본 쿼리를 타입이 있는 객체로 바꿉니다. 알 수 없는 값은undefined가 되므로, 깨진 링크는 일반 폼을 렌더링합니다.- 숨겨진
ref입력은 서버에서 검색 매개변수로 채웁니다. 일반 POST는 스크립트 없이도 이 값을 전달합니다. - 폼에는 실제
method와action이 있습니다. 번들이 로드되지 않았으면 브라우저가 서버 라우트로 POST하고 리디렉션을 따릅니다. - 하이드레이션되면
onSubmit이 이어받아Accept: application/json을 보내므로 방문자는 페이지에 머뭅니다.
허니팟은 같은 FormData에 실려 손대지 않은 채 Ikiform으로 가므로, 이를 채운 봇은 스팸으로 저장됩니다. 시크릿 키를 쓴 POST는 Ikiform의 사람 확인을 건너뛰므로 Spam check를 켜도 여기서는 아무 효과가 없습니다. 이 라우트가 관문입니다.
렌더링과 배포
랜딩 라우트는 Start의 기본값인 서버 렌더링으로 두세요. 쿼리 문자열을 읽어 ref와 결과를 렌더링하는데, 프리렌더링된 페이지는 쿼리 없이 한 번만 빌드되기 때문입니다. 다른 페이지를 위해 tanstackStart() 플러그인에서 prerender를 켠다면 /는 제외하세요.
| 호스트 | 추가할 것 | 시크릿 |
|---|---|---|
| Vercel | nitro 패키지의 nitro() Vite 플러그인 | 프로젝트 설정의 Environment Variables |
| Netlify | Vite 플러그인에 netlify()로 넣는 @netlify/vite-plugin-tanstack-start | Site configuration › Environment variables에서 설정 |
| Cloudflare Workers | viteEnvironment: { name: "ssr" }을 지정한 @cloudflare/vite-plugin과 wrangler.jsonc | wrangler secret put IKIFORM_SECRET_KEY |
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "acme-waitlist",
"compatibility_date": "2025-09-02",
"compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry"
}nodejs_compat는 Workers에서 process.env를 쓸 수 있게 합니다. 없으면 키가 비어 모든 POST가 failed로 실패합니다.
작동 확인하기
vite dev를 실행하고http://localhost:3000/?ref=test를 열어 가입하세요. 새로 고침 없이 감사 문구가 폼을 대신합니다.- 대시보드에서 Submissions를 여세요.
ref가test로 설정된 가입이 있습니다. - JavaScript를 끄고 같은 URL을 새로 고친 뒤 다시 가입하세요. 주소 표시줄에
?joined=yes가 표시되고, 두 번째 가입에도ref가test로 설정됩니다. - JavaScript를 끈 상태로
a@b를 제출하고 페이지가 이메일 메시지,?problem=email과 함께 돌아오는지 확인하세요. - 허니팟을 채워서
_gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. vite dev에서 보낸 POST는localhost출처를 가지므로 테스트 제출로 집계됩니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도localhost에서 보낸 테스트 제출을 매달 50건 받습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 내세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
주의할 점
| 문제 | 해결 방법 |
|---|---|
| 일반 POST에서 원본 JSON이 표시됨 | 브라우저가 Accept 헤더에 application/json을 보냈는데, 일반 폼 POST는 절대 그러지 않습니다. 확장 프로그램이나 프록시가 헤더를 바꾸지 않는지 확인하거나, 대신 fetch가 보내는 커스텀 헤더로 구분하세요. |
프로덕션에서 모든 POST가 failed로 응답함 | 호스트에 IKIFORM_SECRET_KEY가 설정되지 않았습니다. Workers라면 nodejs_compat가 켜져 있고 호환성 날짜가 2025-04-01 이후인지도 확인하세요. 그렇지 않으면 process.env가 비어 있습니다. |
두 파일이 /api/waitlist를 차지함 | Start는 경로마다 핸들러 파일을 하나만 허용합니다. api/waitlist.ts와 api/waitlist/index.ts가 모두 그 경로로 해석되므로 하나만 남기세요. |
unknown_field와 함께 422 | 필드 정의에 ref가 없습니다. 위의 정의처럼 추가하세요. |
402 plan_required | 워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. Settings › Plan and usage에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |
질문
둘 다 가입을 전달할 수 있습니다. 서버 라우트는 페이지로 돌아가는 리디렉션처럼 일반 폼 POST에 대한 응답을 고를 수 있어서 하이드레이션 전에도 폼이 작동합니다. 서버 함수는 React가 로드된 뒤에만 실행되는 폼에 알맞습니다.
랜딩 라우트에서 validateSearch로 ?ref=를 읽어 숨겨진 입력에 렌더링하고, 전달하기 전에 서버에서 다시 검사하세요. 일반 POST 후의 리디렉션이 이를 URL에 되돌려 놓습니다.
쿼리 문자열에서 유입 경로나 결과를 렌더링한다면 안 됩니다. 프리렌더링된 페이지는 쿼리 없이 빌드되기 때문입니다. 그 라우트는 서버 렌더링으로 두고 나머지를 프리렌더링하세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.