뉴스레터 가입 폼6분 분량
TanStack Start로 뉴스레터 가입 폼 만들기
TanStack Start 뉴스레터 폼입니다. 페이지 내 가입용 서버 함수, JavaScript 없는 경우를 위한 서버 라우트, 그리고 실제 가입을 beehiiv로 보내는 구성입니다.
가입 로직을 서버 모듈에 한 번 작성하고 두 곳에서 호출하세요. 폼이 페이지에서 호출하는 createServerFn POST 함수와, JavaScript가 로드되지 않았을 때 같은 폼이 POST하는 서버 라우트입니다. 둘 다 동의 체크박스를 확인하고 시크릿 키로 가입을 폼 백엔드에 전달합니다. 두 번째 서버 라우트가 백엔드의 서명된 웹훅을 받아 beehiiv에 구독을 만듭니다.
메인 가이드의 TanStack Start 탭은 서버 함수 하나와 컴포넌트 하나입니다. JavaScript가 로드된 독자는 그것으로 충분합니다. 이 페이지는 널리 공유하는 사이트에 필요한 것을 더합니다. 하이드레이션 전에 제출한 독자를 위한 경로, 그 경로의 결과 페이지, 그리고 실제 가입을 beehiiv로 넘기는 웹훅 라우트입니다. 제품 설정은 서버 측 문서 가이드에 있습니다.
TanStack Start에서 달라지는 점
서버 함수는 클라이언트가 보내는 RPC 요청으로 호출되므로, onSubmit만 아는 폼은 번들이 실행될 때까지 아무것도 하지 않습니다. TanStack 문서는 JavaScript 없이 서버 함수로 폼을 POST하는 .url 속성을 언급하지만, 그 응답이 어떻게 렌더링되는지는 문서화하지 않았습니다. 서버 라우트가 더 단순합니다. 일반 폼 POST를 받고 일반 리디렉션을 반환합니다. 그래서 폼은 onSubmit과 함께 실제 action도 가집니다.
- 독자가 폼을 제출합니다. JavaScript 로드 여부가 경로를 정합니다.
- 하이드레이션됨: onSubmit이 POST를 막고 폼 데이터로 서버 함수를 호출합니다.
- 서버 함수가 forwardSignup을 실행해 동의를 확인하고 Ikiform으로 POST한 뒤 결과를 컴포넌트에 반환합니다.
- 하이드레이션 안 됨: 브라우저가 /api/newsletter 서버 라우트로 POST하고, 라우트가 같은 forwardSignup을 실행합니다.
- 라우트가 상태나 오류 코드와 함께 303으로 응답하고, /newsletter 페이지가 맞는 메시지를 보여 줍니다.
프로젝트 설정
src/
server/newsletter.ts
components/NewsletterForm.tsx
routes/
__root.tsx
newsletter.tsx
api/newsletter.ts
api/ikiform.ts
.env서버 코드는 process.env에서 시크릿을 읽습니다. VITE_ 접두사는 절대 붙이지 마세요. Vite는 VITE_ 변수를 클라이언트 번들에 넣습니다. API keys에서 submissions:write 스코프로 Ikiform 키를 만들고, 폼의 Fields 탭에 필러의 필드 정의를 붙여 넣으세요.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
BEEHIIV_API_KEY=your_beehiiv_api_key
BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000만들기
가입 모듈
export interface Signup {
email: string;
first_name?: string;
consent: boolean;
source: string;
_gotcha: string;
}
export type Outcome = { ok: true } | { ok: false; field: "email" | "consent" | "failed"; message: string };
const PLACEMENTS = new Set(["footer", "post", "newsletter-page"]);
export function readSignup(form: FormData): Signup {
const source = String(form.get("source") ?? "");
return {
email: String(form.get("email") ?? "").trim(),
first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
consent: form.get("consent") === "true",
source: PLACEMENTS.has(source) ? source : "footer",
_gotcha: String(form.get("_gotcha") ?? ""),
};
}
export async function forwardSignup(signup: Signup, origin: string | undefined): Promise<Outcome> {
if (!signup.consent) {
return { ok: false, field: "consent", message: "Tick the box so we know you want the emails." };
}
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({ ...signup, consent: true }),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { ok: true };
const email = result?.error?.fields?.email?.[0];
if (email) return { ok: false, field: "email", message: email };
return { ok: false, field: "failed", message: result?.error?.message ?? "We couldn't reach the list. Try again." };
}readSignup은FormData를 일반 객체로 바꿉니다. 두 진입점 모두 이를 쓰므로 필드 이름이 한곳에만 있습니다.source는 세 위치 중 하나여야 하므로 조작된 POST가 새 위치를 지어낼 수 없습니다.Origin줄은 브라우저의 origin을 전달합니다. Ikiform은 이 헤더로 무료localhost테스트를 알아보는데, 서버의fetch는 이를 보내지 않기 때문입니다.- 모든 실패는 예외 대신
Outcome을 반환하므로 컴포넌트와 라우트 모두 이를 보여 줄 수 있습니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.서버 함수와 폼
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { forwardSignup, type Outcome, readSignup } from "../server/newsletter";
const subscribe = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return readSignup(data);
})
.handler(({ data }) => forwardSignup(data, getRequestHeader("origin")));
export function NewsletterForm({ source = "footer" }: { source?: string }) {
const send = useServerFn(subscribe);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setOutcome(await send({ data }));
} catch {
setOutcome({ ok: false, field: "failed", message: "Couldn't subscribe. Try again." });
} finally {
setPending(false);
}
}
if (outcome?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
const error = outcome?.ok === false ? outcome : null;
return (
<form method="post" action="/api/newsletter" onSubmit={onSubmit}>
<label>
Email
<input name="email" type="email" required autoComplete="email" aria-invalid={error?.field === "email"} />
</label>
<label>
First name (optional)
<input name="first_name" maxLength={80} autoComplete="given-name" />
</label>
<label>
<input name="consent" type="checkbox" value="true" required aria-invalid={error?.field === "consent"} />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error.message}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}.validator는 클라이언트가 보낸 것을 무엇이든 받습니다. 거기서FormData인지 확인하면 조작된 JSON 호출은 핸들러가 실행되기 전에 실패합니다.getRequestHeader는@tanstack/react-start/server에서 오며, 브라우저가 페이지의 origin과 함께 보낸 RPC 호출 요청을 읽습니다.FormData는event.currentTarget이 아직 폼을 가리키는 첫await전에 만듭니다.useServerFn은 호출이 라우터를 통해 리디렉션과 오류를 따라가게 합니다. 나중에 핸들러에서redirect()를 throw한다면 중요합니다.
JavaScript 없는 경로의 라우트와 페이지
import { createFileRoute } from "@tanstack/react-router";
import { forwardSignup, readSignup } from "../../server/newsletter";
export const Route = createFileRoute("/api/newsletter")({
server: {
handlers: {
POST: async ({ request }) => {
const signup = readSignup(await request.formData());
const outcome = await forwardSignup(signup, request.headers.get("origin") ?? undefined);
const target = new URL("/newsletter", request.url);
target.searchParams.set(outcome.ok ? "status" : "error", outcome.ok ? "subscribed" : outcome.field);
return Response.redirect(target, 303);
},
},
},
});import { createFileRoute } from "@tanstack/react-router";
import { NewsletterForm } from "../components/NewsletterForm";
const MESSAGES = {
consent: "Tick the box so we know you want the emails.",
email: "Check the email address and try again.",
failed: "We couldn't reach the list. Try again.",
};
export const Route = createFileRoute("/newsletter")({
validateSearch: (search: Record<string, unknown>) => ({
status: search.status === "subscribed" ? ("subscribed" as const) : undefined,
error: typeof search.error === "string" && search.error in MESSAGES ? (search.error as keyof typeof MESSAGES) : undefined,
}),
component: NewsletterPage,
});
function NewsletterPage() {
const { status, error } = Route.useSearch();
if (status === "subscribed") return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<main>
<h1>The newsletter</h1>
{error ? <p role="alert">{MESSAGES[error]}</p> : null}
<NewsletterForm source="newsletter-page" />
</main>
);
}라우트는 303으로 리디렉션하므로 결과 페이지를 새로고침해도 폼이 다시 POST되지 않습니다. Ikiform의 메시지가 아니라 오류 코드를 넘기고, validateSearch가 세 코드 밖의 값은 모두 버리므로 아무도 링크로 내 페이지에 자기 텍스트를 넣을 수 없습니다.
구독자를 beehiiv로 전달하기
이 서버 라우트는 Ikiform의 서명된 웹훅을 받아 double_opt_override: "on"으로 beehiiv 구독을 만들므로 beehiiv가 확인 이메일을 보냅니다. 서명은 정확한 바이트를 대상으로 하므로 무엇보다 먼저 request.text()로 본문을 읽습니다. beehiiv 가이드는 이름, referrer, beehiiv의 레이트 리밋 헤더를 추가합니다.
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const Route = createFileRoute("/api/ikiform")({
server: {
handlers: {
POST: async ({ request }) => {
let event;
try {
event = await verifyWebhook({
payload: await request.text(),
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET ?? "",
});
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { email, consent, source } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const answer = await fetch(
`https://api.beehiiv.com/v2/publications/${process.env.BEEHIIV_PUBLICATION_ID}/subscriptions`,
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
email,
double_opt_override: "on",
reactivate_existing: false,
utm_source: typeof source === "string" ? source : "website",
utm_medium: "signup_form",
}),
},
);
if (answer.status === 429) return new Response("beehiiv rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!answer.ok) return new Response(`beehiiv answered ${answer.status}`, { status: 502 });
return new Response(null, { status: 204 });
},
},
},
});Webhooks에 https://your-site.com/api/ikiform를 추가하고 뉴스레터 폼을 고른 뒤 whsec_ 시크릿을 IKIFORM_WEBHOOK_SECRET에 복사하세요.
허니팟과 사람 확인
_gotcha 입력은 가입과 함께 전달되고, Ikiform은 값이 채워진 것을 성공 응답과 함께 웹훅 없이 스팸으로 저장하므로 beehiiv에 닿지 않습니다. 시크릿 키는 Ikiform의 Turnstile 확인을 건너뜁니다. 봇이 허니팟과 필수 체크박스를 통과한다면 Turnstile 가이드처럼 forwardSignup에서 Turnstile 토큰을 검증해 두 진입점 모두에 적용하세요.
렌더링
Start는 기본으로 라우트를 서버에서 렌더링하므로 action을 포함한 폼의 HTML이 첫 응답에 들어 있습니다. 페이지를 정적 파일로 사전 렌더링해도 폼은 그대로 동작합니다. 서버 함수와 두 서버 라우트는 호스트에서 요청 시 실행됩니다.
배포
| 호스트 | 해결 방법 |
|---|---|
| Vercel | Nitro를 추가하세요. nitro를 설치하고 nitro/vite의 nitro()를 Vite 플러그인에 넣습니다. Environment Variables에 변수 네 개를 설정하세요. |
| Netlify | @netlify/vite-plugin-tanstack-start를 추가하고 netlify()를 Vite 플러그인에 넣으세요. 사이트의 환경 설정에 변수를 설정하세요. |
| Cloudflare | nodejs_compat을 켜는 wrangler.jsonc와 함께 @cloudflare/vite-plugin을 플러그인 맨 앞에 추가하세요. 이 플래그와 최근 호환성 날짜가 있으면 Workers가 변수와 시크릿을 process.env에 노출합니다. 웹훅 URL에는 커스텀 도메인을 쓰세요. Ikiform은 workers.dev를 거부합니다. |
localhost에서 무료로 테스트하기
플랜이 없는 워크스페이스는 localhost에서 월 50건의 테스트 제출을 받고, 전달되는 Origin 덕분에 개발 서버의 각 가입이 그중 하나로 집계됩니다. 두 경로를 모두 시험하세요. 한 번은 평소대로, 한 번은 개발자 도구에서 JavaScript를 끄고 해 보세요. Ikiform은 localhost로 웹훅을 전달하지 않으므로 /api/ikiform은 HTTPS URL이 있는 터널로 테스트하세요.
작동 확인하기
- 개발 서버를 실행하고 푸터에서 구독하세요. 폼이 받은편지함을 확인하라는 문구로 바뀌고 URL은 바뀌지 않습니다.
- JavaScript를 끄고 다시 구독하세요.
/newsletter?status=subscribed로 이동합니다. - JavaScript를 끈 상태에서
required를 제거하고 체크박스를 비운 채 제출하세요. 동의 메시지와 함께/newsletter?error=consent로 이동합니다. - 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
- 웹훅을 추가했다면 Webhooks에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께
succeeded로 표시됩니다. - beehiiv를 여세요. 구독이 확인 전까지 대기 상태로 있고, 확인 이메일이 받은편지함에 있습니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
프로덕션에서 시크릿 키가 undefined임 | 호스트에 변수가 없거나, 브라우저에서 실행되는 코드에서 읽었습니다. 시크릿은 서버 함수와 서버 라우트 안에서만 읽으세요. |
| JavaScript 없이 제출한 뒤 JSON 오류 페이지가 나옴 | 폼의 action이 서버 라우트 대신 서버 함수를 가리킵니다. /api/newsletter로 POST하세요. |
로컬 테스트에서 402 plan_required | POST가 localhost origin 없이 Ikiform에 도달했습니다. forwardSignup이 두 호출자 모두에게서 origin을 받는지 확인하세요. |
웹훅 전달이 400으로 실패함 | 시크릿이 엔드포인트의 것과 다르거나, request.text() 전에 무언가 본문을 파싱했습니다. 원본 텍스트를 먼저 읽으세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
질문
TanStack 문서는 서버 함수로 폼을 POST하는 .url 속성을 언급하지만, 대체 경로로는 서버 라우트가 더 단순합니다. 일반 폼 POST를 받아 리디렉션합니다. 이 페이지는 둘 다 쓰며, 그 뒤에 공유 모듈 하나가 있습니다.
서버 함수와 서버 라우트 안에서 process.env로 읽는 서버 전용 환경 변수에 두세요. VITE_ 접두사가 붙은 변수는 브라우저 번들에 들어갑니다.
Ikiform은 스팸에도 실제 가입과 같은 성공 응답을 줍니다. 웹훅은 스팸이 아닌 가입에만 전송되므로 웹훅에서 구독을 만들면 봇이 목록에 들어오지 않습니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.