새 SaaS용 대기자 명단

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

서버 액션으로 Next.js 대기자 명단 만드는 방법

Next.js App Router 대기자 명단: 시크릿 키로 가입을 전달하는 서버 액션, useActionState, 낙관적 감사 문구, 방문자별 한도를 다룹니다.

Preet SutharIkiform 창업자

이메일을 검사하고, 방문자별 한도를 적용하고, 시크릿 키로 가입을 폼 백엔드에 전달하는 서버 액션을 작성하세요. 오류가 상태로 돌아오도록 useActionState로 호출하고, useOptimistic으로 감싸서 버튼을 누르는 순간 감사 문구가 보이고 POST가 실패하면 되돌아가게 하세요.

메인 가이드에는 짧은 Next.js 탭이 있습니다. 서버 액션과 useActionState입니다. 조용한 랜딩 페이지라면 그걸로 충분합니다. 이 페이지는 널리 공유할 대기자 명단을 위해 더 나아갑니다. 즉각적인 감사 문구, 솔직한 오류 메시지, 그리고 서버 측 POST에서는 사라지는 방문자별 한도를 다룹니다.

서버 액션이 바꾸는 것

서버 액션은 시크릿 키를 서버에 두고, 무엇이든 앱 밖으로 나가기 전에 이메일을 검사할 수 있게 해 줍니다. 또 모든 POST를 서버의 IP 주소로 옮기는데, 이로 인해 세 가지 영향이 있습니다.

Item브라우저에서 Ikiform으로 POST시크릿 키를 쓰는 서버 액션
Ikiform이 보는 대상각 방문자의 IP모든 방문자에 대해 서버의 IP
방문자별 레이트 리밋Ikiform의 IP별 한도직접 추가, 아래 참고
허용 도메인과 사람 확인실행됨키가 내 POST임을 증명하므로 건너뜀
호출할 수 있는 사람허용 도메인에서 누구나내 페이지로 POST할 수 있는 누구나

마지막 행이 중요합니다. Next.js는 서버 액션을 공개 엔드포인트로 취급합니다. 폼뿐 아니라 누구든 조작한 POST로 호출할 수 있습니다. 시크릿 키가 Ikiform의 출처 검사를 건너뛰므로 액션이 유일한 관문이고, 그래서 자체 한도가 필요합니다.

가입이 Next.js 서버 액션을 거치는 과정폼useOptimistic이감사 문구 표시서버 액션이메일 검사, IP별제한Ikiform시크릿 키, 가입 저장액션 상태가입 완료 또는 오류오류 상태POST 없음, 폼복귀FormDataJSON결과한도 초과가입이 Next.js 서버 액션을 거치는 과정폼useOptimistic이 감사 문구표시서버 액션이메일 검사, IP별 제한Ikiform시크릿 키, 가입 저장액션 상태가입 완료 또는 오류오류 상태POST 없음, 폼복귀FormDataJSON결과한도 초과
  1. 방문자가 버튼을 누릅니다. useOptimistic이 폼을 즉시 감사 문구로 바꿉니다.
  2. 서버 액션이 이메일을 검사하고 방문자의 최근 시도 횟수를 IP별로 셉니다.
  3. 한도 안이면 시크릿 키로 가입을 Ikiform에 POST합니다.
  4. 결과가 액션 상태가 됩니다. 성공하면 감사 문구가 유지되고, 오류가 나면 메시지와 입력한 이메일이 담긴 폼이 돌아옵니다.
  5. 한도를 넘으면 아무것도 POST하지 않고 오류 상태가 바로 돌아옵니다.
낙관적 문구는 액션이 실행되는 동안만 유지됩니다. 화면에 남는 것은 액션 상태가 결정합니다.

만들기

시크릿 키

대시보드에서 API keys를 열고 submissions:write 스코프가 있는 시크릿 키를 만든 뒤, 서버 전용 변수로 저장하세요. 절대 NEXT_PUBLIC_ 접두사를 붙이지 마세요.

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

방문자별 한도

IP당 분당 가입 5회는 사람에게는 넉넉하고 스크립트에게는 쓸모없는 수준입니다. 이 카운터는 서버 메모리에 있습니다. server-only는 클라이언트 컴포넌트가 이를 가져오면 빌드가 실패하게 합니다.

lib/rate-limit.ts
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를 읽으세요. 방문자가 직접 설정할 수 있는 헤더는 한도 역할을 전혀 못 합니다.

서버 액션

app/waitlist/actions.ts
"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를 쓰세요.

낙관적 감사 문구가 있는 폼

app/waitlist/waitlist-form.tsx
"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을 빼세요. 나머지 코드는 그대로입니다.

페이지

app/waitlist/page.tsx
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를 읽으면 페이지가 요청마다 렌더링됩니다.

작동 확인하기

  1. next dev를 실행하고 /waitlist?ref=test를 열어 가입하세요. 요청이 끝나기 전에 감사 문구가 보입니다.
  2. 대시보드에서 Submissions를 여세요. ref가 test로 설정된 가입이 있습니다.
  3. 1분 안에 여섯 번 제출하세요. 여섯 번째에는 한도 메시지가 나오고, 제출은 다섯 건만 저장됩니다.
  4. IKIFORM_SECRET_KEY를 잘못된 값으로 설정하고 제출하세요. 낙관적 문구가 되돌아가고, 폼이 이메일을 그대로 둔 채 오류를 보여 줍니다.
  5. 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받으며, 폼에 email 필드가 있으면 Reply(답장)는 제출자에게 갑니다.
  6. 허니팟을 채워서 _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처럼 폼의 액션 안에서 호출하세요.

질문

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