# TanStack Start로 뉴스레터 가입 폼 만들기

> TanStack Start 뉴스레터 폼입니다. 페이지 내 가입용 서버 함수, JavaScript 없는 경우를 위한 서버 라우트, 그리고 실제 가입을 beehiiv로 보내는 구성입니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/newsletter-signup/tanstack-start

가입 로직을 서버 모듈에 한 번 작성하고 두 곳에서 호출하세요. 폼이 페이지에서 호출하는 `createServerFn` POST 함수와, JavaScript가 로드되지 않았을 때 같은 폼이 POST하는 서버 라우트입니다. 둘 다 동의 체크박스를 확인하고 시크릿 키로 가입을 폼 백엔드에 전달합니다. 두 번째 서버 라우트가 백엔드의 서명된 웹훅을 받아 beehiiv에 구독을 만듭니다.

[메인 가이드의 TanStack Start 탭](https://ikiform.com/ko/guides/newsletter-signup.md#form)은 서버 함수 하나와 컴포넌트 하나입니다. JavaScript가 로드된 독자는 그것으로 충분합니다. 이 페이지는 널리 공유하는 사이트에 필요한 것을 더합니다. 하이드레이션 전에 제출한 독자를 위한 경로, 그 경로의 결과 페이지, 그리고 실제 가입을 beehiiv로 넘기는 웹훅 라우트입니다. 제품 설정은 [서버 측 문서 가이드](https://ikiform.com/ko/docs/guides/server.md)에 있습니다.

## TanStack Start에서 달라지는 점

서버 함수는 클라이언트가 보내는 RPC 요청으로 호출되므로, `onSubmit`만 아는 폼은 번들이 실행될 때까지 아무것도 하지 않습니다. TanStack 문서는 JavaScript 없이 서버 함수로 폼을 POST하는 `.url` 속성을 언급하지만, 그 응답이 어떻게 렌더링되는지는 문서화하지 않았습니다. 서버 라우트가 더 단순합니다. 일반 폼 POST를 받고 일반 리디렉션을 반환합니다. 그래서 폼은 `onSubmit`과 함께 실제 `action`도 가집니다.

다이어그램: 하나의 TanStack Start 폼이 JavaScript 유무와 관계없이 같은 가입 코드에 도달하는 방식

1. 독자가 폼을 제출합니다. JavaScript 로드 여부가 경로를 정합니다.
2. 하이드레이션됨: onSubmit이 POST를 막고 폼 데이터로 서버 함수를 호출합니다.
3. 서버 함수가 forwardSignup을 실행해 동의를 확인하고 Ikiform으로 POST한 뒤 결과를 컴포넌트에 반환합니다.
4. 하이드레이션 안 됨: 브라우저가 /api/newsletter 서버 라우트로 POST하고, 라우트가 같은 forwardSignup을 실행합니다.
5. 라우트가 상태나 오류 코드와 함께 303으로 응답하고, /newsletter 페이지가 맞는 메시지를 보여 줍니다.

Ikiform과 통신하는 코드는 forwardSignup뿐이므로 두 경로 모두 같은 방식으로 검증하고 오류를 보고합니다.

## 프로젝트 설정

`프로젝트 트리`:

```text
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** 탭에 [필러의 필드 정의](https://ikiform.com/ko/guides/newsletter-signup.md#fields)를 붙여 넣으세요.

`.env`:

```bash
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
```

## 만들기

### 가입 모듈

`src/server/newsletter.ts`:

```ts
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`을 반환하므로 컴포넌트와 라우트 모두 이를 보여 줄 수 있습니다.

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

### 서버 함수와 폼

`src/components/NewsletterForm.tsx`:

```tsx
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 없는 경로의 라우트와 페이지

`src/routes/api/newsletter.ts`:

```ts
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);
      },
    },
  },
});
```

`src/routes/newsletter.tsx`:

```tsx
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 가이드](https://ikiform.com/ko/guides/newsletter-signup/beehiiv.md)는 이름, referrer, beehiiv의 레이트 리밋 헤더를 추가합니다.

`src/routes/api/ikiform.ts`:

```ts
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 가이드](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)처럼 `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이 있는 터널로 테스트하세요.

## 작동 확인하기

1. 개발 서버를 실행하고 푸터에서 구독하세요. 폼이 받은편지함을 확인하라는 문구로 바뀌고 URL은 바뀌지 않습니다.
2. JavaScript를 끄고 다시 구독하세요. `/newsletter?status=subscribed`로 이동합니다.
3. JavaScript를 끈 상태에서 `required`를 제거하고 체크박스를 비운 채 제출하세요. 동의 메시지와 함께 `/newsletter?error=consent`로 이동합니다.
4. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
5. 웹훅을 추가했다면 **Webhooks**에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께 `succeeded`로 표시됩니다.
6. beehiiv를 여세요. 구독이 확인 전까지 대기 상태로 있고, 확인 이메일이 받은편지함에 있습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 프로덕션에서 시크릿 키가 `undefined`임 | 호스트에 변수가 없거나, 브라우저에서 실행되는 코드에서 읽었습니다. 시크릿은 서버 함수와 서버 라우트 안에서만 읽으세요. |
| JavaScript 없이 제출한 뒤 JSON 오류 페이지가 나옴 | 폼의 `action`이 서버 라우트 대신 서버 함수를 가리킵니다. `/api/newsletter`로 POST하세요. |
| 로컬 테스트에서 `402 plan_required` | POST가 `localhost` origin 없이 Ikiform에 도달했습니다. `forwardSignup`이 두 호출자 모두에게서 origin을 받는지 확인하세요. |
| 웹훅 전달이 `400`으로 실패함 | 시크릿이 엔드포인트의 것과 다르거나, `request.text()` 전에 무언가 본문을 파싱했습니다. 원본 텍스트를 먼저 읽으세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |

## 질문

### TanStack Start 서버 함수는 JavaScript 없이 동작하나요?

TanStack 문서는 서버 함수로 폼을 POST하는 .url 속성을 언급하지만, 대체 경로로는 서버 라우트가 더 단순합니다. 일반 폼 POST를 받아 리디렉션합니다. 이 페이지는 둘 다 쓰며, 그 뒤에 공유 모듈 하나가 있습니다.

### TanStack Start에서 시크릿은 어디에 두나요?

서버 함수와 서버 라우트 안에서 process.env로 읽는 서버 전용 환경 변수에 두세요. VITE_ 접두사가 붙은 변수는 브라우저 번들에 들어갑니다.

### 서버 함수 안에서 구독자를 beehiiv에 추가하지 않는 이유는 무엇인가요?

Ikiform은 스팸에도 실제 가입과 같은 성공 응답을 줍니다. 웹훅은 스팸이 아닌 가입에만 전송되므로 웹훅에서 구독을 만들면 봇이 목록에 들어오지 않습니다.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [뉴스레터 가입 폼](https://ikiform.com/ko/guides/newsletter-signup.md): 직접 작성한 마크업으로 뉴스레터 가입 폼을 만들고, 동의를 기록하고, 봇 가입을 막고, 각 구독자를 Kit, MailerLite, beehiiv 등 어떤 도구로든 넘깁니다.
- [beehiiv](https://ikiform.com/ko/guides/newsletter-signup/beehiiv.md): 내 가입 폼을 beehiiv에 연결합니다. 릴레이가 더블 옵트인을 켜고, 위치를 UTM source로, 페이지를 referrer로 해서 각 구독을 만듭니다.
- [더블 옵트인](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md): Ikiform은 확인 이메일을 보내지 않으므로 더블 옵트인은 이메일 도구에서 실행됩니다. 릴레이가 각 가입을 미확인으로 추가하고 도구가 링크를 보냅니다.
- [React Router 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/react-router.md): React Router 푸터 뉴스레터 폼입니다. useFetcher가 JavaScript 없이도 동작하는 /newsletter 라우트로 POST하고, 리소스 라우트가 MailerLite로 넘깁니다.
- [정적 사이트](https://ikiform.com/ko/guides/newsletter-signup/static-site.md): Hugo, Jekyll, Eleventy, Astro 정적 사이트에 뉴스레터 가입 폼을 추가하고, 이메일 도구의 API 키는 페이지에서 빼고, 확인은 도구에서 합니다.
- [Kit (구 ConvertKit)](https://ikiform.com/ko/guides/newsletter-signup/kit.md): 구독자를 만들고, 인센티브 이메일을 위해 Kit 폼에 추가하고, 가입 위치를 태그하는 릴레이로 내 뉴스레터 폼을 Kit에 연결합니다.
