새 SaaS용 대기자 명단5분 분량
서버 액션으로 Next.js 대기자 명단 만드는 방법
Next.js App Router 대기자 명단: 시크릿 키로 가입을 전달하는 서버 액션, useActionState, 낙관적 감사 문구, 방문자별 한도를 다룹니다.
이메일을 검사하고, 방문자별 한도를 적용하고, 시크릿 키로 가입을 폼 백엔드에 전달하는 서버 액션을 작성하세요. 오류가 상태로 돌아오도록 useActionState로 호출하고, useOptimistic으로 감싸서 버튼을 누르는 순간 감사 문구가 보이고 POST가 실패하면 되돌아가게 하세요.
메인 가이드에는 짧은 Next.js 탭이 있습니다. 서버 액션과 useActionState입니다. 조용한 랜딩 페이지라면 그걸로 충분합니다. 이 페이지는 널리 공유할 대기자 명단을 위해 더 나아갑니다. 즉각적인 감사 문구, 솔직한 오류 메시지, 그리고 서버 측 POST에서는 사라지는 방문자별 한도를 다룹니다.
서버 액션이 바꾸는 것
서버 액션은 시크릿 키를 서버에 두고, 무엇이든 앱 밖으로 나가기 전에 이메일을 검사할 수 있게 해 줍니다. 또 모든 POST를 서버의 IP 주소로 옮기는데, 이로 인해 세 가지 영향이 있습니다.
| Item | 브라우저에서 Ikiform으로 POST | 시크릿 키를 쓰는 서버 액션 |
|---|---|---|
| Ikiform이 보는 대상 | 각 방문자의 IP | 모든 방문자에 대해 서버의 IP |
| 방문자별 레이트 리밋 | Ikiform의 IP별 한도 | 직접 추가, 아래 참고 |
| 허용 도메인과 사람 확인 | 실행됨 | 키가 내 POST임을 증명하므로 건너뜀 |
| 호출할 수 있는 사람 | 허용 도메인에서 누구나 | 내 페이지로 POST할 수 있는 누구나 |
마지막 행이 중요합니다. Next.js는 서버 액션을 공개 엔드포인트로 취급합니다. 폼뿐 아니라 누구든 조작한 POST로 호출할 수 있습니다. 시크릿 키가 Ikiform의 출처 검사를 건너뛰므로 액션이 유일한 관문이고, 그래서 자체 한도가 필요합니다.
- 방문자가 버튼을 누릅니다. useOptimistic이 폼을 즉시 감사 문구로 바꿉니다.
- 서버 액션이 이메일을 검사하고 방문자의 최근 시도 횟수를 IP별로 셉니다.
- 한도 안이면 시크릿 키로 가입을 Ikiform에 POST합니다.
- 결과가 액션 상태가 됩니다. 성공하면 감사 문구가 유지되고, 오류가 나면 메시지와 입력한 이메일이 담긴 폼이 돌아옵니다.
- 한도를 넘으면 아무것도 POST하지 않고 오류 상태가 바로 돌아옵니다.
만들기
시크릿 키
대시보드에서 API keys를 열고 submissions:write 스코프가 있는 시크릿 키를 만든 뒤, 서버 전용 변수로 저장하세요. 절대 NEXT_PUBLIC_ 접두사를 붙이지 마세요.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.방문자별 한도
IP당 분당 가입 5회는 사람에게는 넉넉하고 스크립트에게는 쓸모없는 수준입니다. 이 카운터는 서버 메모리에 있습니다. server-only는 클라이언트 컴포넌트가 이를 가져오면 빌드가 실패하게 합니다.
import "server-only";
const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();
export function allow(key: string, now = Date.now()) {
const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
const allowed = recent.length < LIMIT;
if (allowed) recent.push(now);
if (hits.size > 10_000) hits.clear();
hits.set(key, recent);
return allowed;
}- 메모리는 서버 인스턴스끼리 공유되지 않습니다. 단일 Node 서버나 컨테이너에서는 정확합니다. 서버리스 호스팅에서는 인스턴스마다 자체 카운트를 가지므로 과속 방지턱 정도로 여기고, 트래픽이 늘면 카운터를 Redis 같은 공유 저장소로 옮기세요.
- 호스트가 설정하는 IP 헤더만 신뢰하세요. Vercel은
x-forwarded-for를 설정하고, Cloudflare 뒤에서는cf-connecting-ip를 읽으세요. 방문자가 직접 설정할 수 있는 헤더는 한도 역할을 전혀 못 합니다.
서버 액션
"use server";
import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";
export interface JoinState {
status: "idle" | "joined" | "error";
message: string;
email?: string;
}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
const email = String(formData.get("email") ?? "").trim();
if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };
const incoming = await headers();
const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };
let response: Response;
try {
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: incoming.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { status: "error", message: "We couldn't reach the list. Try again.", email };
}
if (response.status === 429) {
const wait = response.headers.get("Retry-After") ?? "60";
return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
}
const result = await response.json().catch(() => null);
if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
return {
status: "error",
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
};
}- 비용이 적은 검사를 먼저 실행합니다. 잘못된 이메일은 요청을 한 번도 쓰지 않습니다.
AbortSignal.timeout은 느린 네트워크 때문에 버튼이 대기 상태에 영원히 머무는 것을 막습니다.- Ikiform의
429는Retry-After와 함께 따로 읽습니다. 모든 방문자가 IP 하나 뒤에 있으므로 바쁜 것은 이 사람이 아니라 폼 전체이기 때문입니다. - 모든 오류는 이메일을 함께 반환하므로 입력란에 다시 표시할 수 있습니다. React는 액션 후 폼을 초기화하고,
defaultValue가 주소를 되돌려 놓습니다. - Ikiform은 브라우저의
Origin헤더로 무료 테스트 제출을 세는데, 서버 자체의fetch는 이 헤더를 보내지 않으므로 전달합니다. - SDK의
submit()은Authorization헤더를 보내지 않아 허용 도메인 검사를 건너뛸 수 없습니다. 여기서는 키와 함께fetch를 쓰세요.
낙관적 감사 문구가 있는 폼
"use client";
import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";
const initial: JoinState = { status: "idle", message: "" };
export function WaitlistForm({ source }: { source: string }) {
const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
const [shown, showJoined] = useOptimistic(
state,
(_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
);
function join(formData: FormData) {
showJoined(String(formData.get("email") ?? ""));
dispatch(formData);
}
if (shown.status === "joined") {
return (
<p role="status" aria-busy={pending}>
{shown.message}
</p>
);
}
return (
<form action={join}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
<input type="hidden" name="ref" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
<button disabled={pending}>Join the waitlist</button>
</form>
);
}useOptimistic은 액션 안에서 설정해야 합니다.join이 폼의 액션이므로 React가 이를 트랜지션 안에서 실행하고, 낙관적 문구는dispatch가 끝날 때까지 유지됩니다.- 성공하면 액션 상태가
joined가 되고 문구가 최종 메시지로 바뀌어 유지됩니다. 오류가 나면 낙관적 값이 사라지고,state.email과 메시지가 담긴 폼이 돌아옵니다. aria-busy는 스크린 리더에 문구가 아직 확인 중임을 알립니다.
join으로 감싸면 클라이언트 함수가 되므로 이 폼에는 JavaScript가 필요합니다. 즉각적인 감사 문구보다 하이드레이션 전 POST가 더 중요하다면 dispatch를 action에 바로 넘기고 useOptimistic을 빼세요. 나머지 코드는 그대로입니다.페이지
import { WaitlistForm } from "./waitlist-form";
export default async function Page({ searchParams }: PageProps<"/waitlist">) {
const { ref } = await searchParams;
const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";
return (
<main>
<h1>Acme is almost here</h1>
<p>Leave your email and we'll send you an invite the day it opens.</p>
<WaitlistForm source={source} />
</main>
);
}페이지는 URL에서 ?ref=를 읽어 폼에 넘기므로, /waitlist?ref=newsletter 같은 링크는 가입마다 유입 경로를 태그합니다. PageProps는 Next.js가 생성하는 라우트 인식 타입이며, searchParams를 읽으면 페이지가 요청마다 렌더링됩니다.
작동 확인하기
next dev를 실행하고/waitlist?ref=test를 열어 가입하세요. 요청이 끝나기 전에 감사 문구가 보입니다.- 대시보드에서 Submissions를 여세요.
ref가test로 설정된 가입이 있습니다. - 1분 안에 여섯 번 제출하세요. 여섯 번째에는 한도 메시지가 나오고, 제출은 다섯 건만 저장됩니다.
IKIFORM_SECRET_KEY를 잘못된 값으로 설정하고 제출하세요. 낙관적 문구가 되돌아가고, 폼이 이메일을 그대로 둔 채 오류를 보여 줍니다.- 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받으며, 폼에
email필드가 있으면 Reply(답장)는 제출자에게 갑니다. - 허니팟을 채워서
_gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
Ikiform에서 403 forbidden | 키가 다른 워크스페이스의 것이거나, 폐기되었거나, submissions:write가 없습니다. 그 스코프가 있는 시크릿 키를 만드세요. |
429 rate_limited | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. Retry-After만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
| 모든 방문자가 한꺼번에 한도에 걸림 | IP 헤더가 없어서 모두가 unknown으로 집계됩니다. Cloudflare 뒤라면 cf-connecting-ip처럼 호스트가 설정하는 헤더를 읽으세요. |
| 감사 문구가 잠깐 보였다가 메시지 없이 폼이 돌아옴 | 액션이 상태를 반환하지 않고 예외를 던졌습니다. 예제처럼 모든 실패 경로에서 JoinState를 반환하세요. |
| 낙관적 업데이트가 트랜지션 밖에서 일어났다는 경고 | showJoined를 이벤트 핸들러에서 호출했습니다. join처럼 폼의 액션 안에서 호출하세요. |
질문
둘 다 됩니다. 브라우저 POST는 Ikiform의 방문자별 한도, 허용 도메인, 사람 확인을 유지합니다. 서버 액션은 엔드포인트를 숨기고, 입력을 먼저 검사하고, 하이드레이션 전에도 작동하지만, 모든 POST가 서버에서 오므로 방문자별 한도를 직접 추가해야 합니다.
네. 폼의 action prop처럼 같은 액션 안에서 낙관적 값을 설정하고 useActionState의 dispatch 함수를 호출하세요. 낙관적 값은 액션이 실행되는 동안 보이고, 끝나면 액션 상태로 바뀝니다.
아니요. 서버 액션이 각 가입을 Ikiform으로 전달하면 Ikiform이 저장하고, 스팸을 거르고, 이메일을 보내고, 웹훅을 보냅니다. 앱은 레이트 리밋 카운터 말고는 상태를 갖지 않습니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.