# React Router로 뉴스레터 가입 폼 만들기

> React Router 푸터 뉴스레터 폼입니다. useFetcher가 JavaScript 없이도 동작하는 /newsletter 라우트로 POST하고, 리소스 라우트가 MailerLite로 넘깁니다.

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

액션을 자체 페이지가 있는 실제 `/newsletter` 라우트에 두고, `useFetcher`로 만든 푸터 폼에서 그곳으로 POST하세요. JavaScript가 있으면 fetcher가 백그라운드에서 제출하고 독자는 글에 머뭅니다. 없으면 같은 폼이 일반 POST가 되어 결과가 표시된 `/newsletter` 페이지로 이동합니다. 액션은 동의를 확인하고 가입을 폼 백엔드로 전달하며, 리소스 라우트가 서명된 웹훅을 MailerLite 구독자로 바꿉니다.

[메인 가이드의 React Router 탭](https://ikiform.com/ko/guides/newsletter-signup.md#form)은 `useFetcher`로 리소스 라우트에 POST합니다. JavaScript가 로드된 뒤에는 동작합니다. 이 페이지는 그 전에 일어나는 일을 고치고, 가입이 페이지의 모든 loader를 다시 불러오지 않게 하며, MailerLite용 웹훅 라우트를 추가합니다. 제품 설정은 [서버 측 문서 가이드](https://ikiform.com/ko/docs/guides/server.md)에 있습니다.

## 액션에 페이지가 필요한 이유

React Router 문서에 따르면 `fetcher.Form`은 JavaScript가 로드될 때까지 일반 HTML 폼입니다. 그때 제출하면 폼의 `action` URL로 문서 POST가 일어나고, 브라우저는 그 라우트가 렌더링하는 것을 보여 줍니다. 리소스 라우트는 아무것도 렌더링하지 않고 데이터를 반환합니다. 그래서 일찍 클릭한 독자는 원본 JSON을 보게 됩니다. 액션에 기본 컴포넌트가 있는 라우트를 주면 같은 POST가 페이지에 도착합니다.

다이어그램: JavaScript 없는 독자가 푸터 폼을 제출한 뒤 보는 것

1. 하이드레이션 전에는 fetcher.Form이 일반 폼이므로 브라우저가 /newsletter로 POST하고 그곳으로 이동합니다.
2. /newsletter가 리소스 라우트라면 브라우저가 액션의 JSON을 페이지로 보여 줍니다.
3. /newsletter에 기본 컴포넌트가 있다면 React Router가 액션을 실행한 뒤 결과를 actionData로 넣어 페이지를 렌더링합니다.
4. JavaScript가 로드되면 두 버전 모두 같게 동작합니다. fetcher가 백그라운드에서 POST하고 독자는 그 자리에 머뭅니다.

해결책은 기본 export 하나입니다. 액션과 푸터 폼은 그대로입니다.

## 프로젝트 설정

서버 렌더링을 켠 프레임워크 모드를 씁니다. `react-router.config.ts`의 `ssr: true`이며 기본값입니다.

`프로젝트 트리`:

```text
app/
  root.tsx
  routes.ts
  routes/
    home.tsx
    newsletter.tsx
    api.ikiform.ts
  components/newsletter-form.tsx
react-router.config.ts
.env
```

`app/routes.ts`:

```ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("newsletter", "routes/newsletter.tsx"),
  route("api/ikiform", "routes/api.ikiform.ts"),
] satisfies RouteConfig;
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

**API keys**에서 `submissions:write` 스코프로 Ikiform 키를 만들고, 폼의 **Fields** 탭에 [필러의 필드 정의](https://ikiform.com/ko/guides/newsletter-signup.md#fields)를 붙여 넣으세요.

## 만들기

### /newsletter 라우트

`app/routes/newsletter.tsx`:

```tsx
import { data } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";
import type { Route } from "./+types/newsletter";

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  if (form.get("consent") !== "true") {
    return data(
      { ok: false as const, field: "consent", message: "Tick the box so we know you want the emails." },
      { status: 422 },
    );
  }

  const source = String(form.get("source") ?? "");
  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: request.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email: String(form.get("email") ?? "").trim(),
      first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
      consent: true,
      source: PLACEMENTS.includes(source) ? source : "footer",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true as const };
  const email = result?.error?.fields?.email?.[0];
  return data(
    {
      ok: false as const,
      field: email ? "email" : "form",
      message: String(email ?? result?.error?.message ?? "We couldn't reach the list. Try again."),
    },
    { status: response?.status ?? 502 },
  );
}

export function meta() {
  return [{ title: "Newsletter" }];
}

export default function Newsletter({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>The monthly newsletter</h1>
      <NewsletterForm source="newsletter-page" initial={actionData} />
    </main>
  );
}
```

- 오류는 throw하지 않고 `data()`와 `4xx` 상태로 반환하므로 오류 경계가 아니라 폼에 도달합니다.
- 동의 확인은 네트워크 호출 전에 실행됩니다. 체크박스가 비어 있어도 비용이 들지 않습니다.
- `Origin` 줄은 브라우저의 origin을 전달합니다. Ikiform은 이 헤더로 무료 `localhost` 테스트를 알아보는데, 서버의 `fetch`는 이를 보내지 않기 때문입니다.
- 기본 컴포넌트는 `actionData`를 받아 같은 폼을 렌더링하므로 JavaScript 없는 결과와 페이지 내 결과가 똑같아 보입니다.

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

### 푸터 폼

`app/components/newsletter-form.tsx`:

```tsx
import { useFetcher } from "react-router";
import type { Route } from "../routes/+types/newsletter";
import type { action } from "../routes/newsletter";

interface Props {
  source: "footer" | "post" | "newsletter-page";
  initial?: Route.ComponentProps["actionData"];
}

export function NewsletterForm({ source, initial }: Props) {
  const fetcher = useFetcher<typeof action>({ key: `newsletter-${source}` });
  const result = fetcher.data ?? initial;
  const busy = fetcher.state !== "idle";

  if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = result?.ok === false ? result : null;

  return (
    <fetcher.Form method="post" action="/newsletter">
      <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={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
    </fetcher.Form>
  );
}
```

- 백그라운드 제출 뒤에는 `fetcher.data`가, 문서 POST 뒤에는 `initial`이 액션의 응답을 담습니다. 존재하는 쪽이 쓰입니다.
- `fetcher.state`는 `idle`로 돌아가기 전에 `submitting`, 그다음 `loading`이므로 두 상태 모두에서 버튼이 비활성화됩니다.
- 위치마다 `key`를 주면 푸터 폼과 `/newsletter` 페이지 폼이 함께 화면에 있을 때 상태가 분리됩니다.

### 페이지 데이터를 다시 불러오지 않고 마운트하기

`app/root.tsx`:

```tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";
import { Outlet } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/newsletter") return false;
  return defaultShouldRevalidate;
}

export default function App() {
  return (
    <>
      <Outlet />
      <footer>
        <p>One email a month. Unsubscribe from any issue.</p>
        <NewsletterForm source="footer" />
      </footer>
    </>
  );
}
```

fetcher가 액션에 제출하면 React Router는 기본으로 일치하는 모든 라우트의 loader를 재검증합니다. 뉴스레터 가입은 그들이 불러오는 것을 아무것도 바꾸지 않으므로, 루트의 `shouldRevalidate`가 제출이 `/newsletter`로 갔을 때 그 작업을 건너뜁니다. loader 비용이 큰 라우트에도 같은 export를 추가하세요. 폼은 `Layout`이 아니라 기본 `App` 컴포넌트에 넣습니다. `Layout`은 오류 경계도 감싸기 때문입니다.

## 구독자를 MailerLite로 전달하기

웹훅은 앱 외부에서 오므로 이것은 리소스 라우트입니다. 컴포넌트 없는 `action`이며, React Router 문서가 외부 호출자에게 권하는 대로 일반 `Response` 객체를 반환합니다. 서명은 정확한 바이트를 대상으로 하므로 본문을 먼저 텍스트로 읽습니다. 모든 가입을 `unconfirmed`로 보내므로 새 목록에 맞습니다. 목록에 이미 확인된 독자가 있다면 대신 [MailerLite 가이드](https://ikiform.com/ko/guides/newsletter-signup/mailerlite.md)의 릴레이를 쓰세요. 주소를 먼저 조회하므로 다시 가입한 확인된 독자가 상태를 유지합니다.

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

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/api.ikiform";

export async function action({ request }: Route.ActionArgs) {
  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, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [process.env.MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

**Webhooks**에 `https://your-site.com/api/ikiform`를 추가하고 뉴스레터 폼을 고른 뒤 `whsec_` 시크릿을 복사하세요.

## 허니팟과 사람 확인

`_gotcha` 입력은 전달되고, Ikiform은 값이 채워진 것을 성공 응답과 함께 웹훅 없이 스팸으로 저장하므로 MailerLite에 닿지 않습니다. 시크릿 키는 Ikiform의 Turnstile 확인을 건너뛰며, 이제 모든 POST가 서버의 IP에서 옵니다. 봇이 통과한다면 [Turnstile 가이드](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md)처럼 `fetch` 전에 액션에서 Turnstile 토큰을 검증하세요.

## 렌더링과 사전 렌더링

`ssr: true`를 유지한 채 `prerender` 옵션으로 글을 사전 렌더링할 수 있습니다. 서버는 여전히 `/newsletter`와 웹훅 라우트를 요청 시 실행합니다. SPA 모드인 `ssr: false`는 여기서 동작하지 않습니다. React Router 문서에 따르면 런타임 서버 없이는 액션이 금지되며, 액션이 있는 라우트가 있으면 빌드가 실패합니다. 완전히 정적인 React Router 사이트는 대신 `@ikiform/sdk`로 브라우저에서 POST해야 합니다.

## 배포

| 호스트 | 해결 방법 |
| --- | --- |
| Vercel | 설정 없이 배포됩니다. Vercel은 `react-router.config.ts`의 `presets`에 `@vercel/react-router/vite`의 `vercelPreset()`을 추가하길 권합니다. **Environment Variables**에 변수를 설정하세요. |
| Netlify | `@netlify/vite-plugin-react-router`를 설치해 Vite 플러그인에 추가하세요. SSR은 Netlify Functions로 실행됩니다. 사이트의 환경 설정에 변수를 설정하세요. |
| Cloudflare | Workers용 React Router 템플릿에서 시작하세요. 시크릿은 `process.env` 대신 `import { env } from "cloudflare:workers"`로 읽습니다. React Router 8은 더 이상 이를 `context.cloudflare.env`에 넣지 않습니다. 웹훅에는 커스텀 도메인을 쓰세요. Ikiform은 `workers.dev`를 거부합니다. |

## localhost에서 무료로 테스트하기

플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받고, 전달되는 `Origin` 덕분에 개발 서버의 각 가입이 그중 하나로 집계됩니다. 일찍 클릭하는 경로는 개발자 도구에서 JavaScript를 꺼서 테스트하세요. Ikiform은 `localhost`로 웹훅을 전달하지 않습니다. `/api/ikiform`을 테스트하려면 HTTPS URL이 있는 터널로 개발 서버를 노출하세요.

## 작동 확인하기

1. `react-router dev`를 실행하고 글을 열어 푸터에서 구독하세요. 받은편지함을 확인하라는 문구가 나타나고 URL은 바뀌지 않습니다.
2. 네트워크 탭을 열고 새 페이지에서 다시 구독하세요. `/newsletter`로 가는 요청 하나만 있고 그 뒤에 loader 요청이 없습니다.
3. JavaScript를 끄고 구독하세요. JSON이 아니라 같은 문구가 있는 `/newsletter`로 이동합니다.
4. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
5. 웹훅을 추가했다면 **Webhooks**에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께 `succeeded`로 표시됩니다.
6. MailerLite를 여세요. 구독자가 그룹에 미확인으로 있고 확인 이메일이 받은편지함에 있습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 액션에 관한 오류로 빌드가 실패함 | `ssr`이 `false`입니다. 액션에는 런타임 서버가 필요합니다. SSR을 켜고 정적 페이지에는 `prerender`를 쓰세요. |
| 가입할 때마다 페이지 데이터가 다시 불러와짐 | loader는 모든 액션 뒤에 재검증됩니다. `formAction`이 `/newsletter`일 때 `false`를 반환하는 `shouldRevalidate`를 추가하세요. |
| Cloudflare에서 시크릿 키가 `undefined`임 | Workers는 기본으로 `process.env`를 채우지 않습니다. 액션에서 `cloudflare:workers`의 `env`로 시크릿을 읽으세요. |
| 로컬 테스트에서 `402 plan_required` | POST에 `localhost` origin이 없었습니다. 액션의 `Origin` 줄을 확인하고 `localhost`로 앱을 여세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |

## 질문

### useFetcher는 JavaScript 없이 동작하나요?

네. fetcher.Form은 일반 HTML 폼을 렌더링하므로 하이드레이션 전에는 액션 URL로 POST하고 이동합니다. 그 URL을 컴포넌트가 있는 라우트로 만드세요. 그렇지 않으면 독자는 액션의 원본 데이터를 보게 됩니다.

### 뉴스레터 액션은 리소스 라우트여야 하나요?

사람이 제출하는 폼이라면 아닙니다. JavaScript 없는 POST가 페이지를 보여 주도록 기본 컴포넌트가 있는 라우트를 쓰세요. 리소스 라우트는 웹훅처럼 브라우저가 아닌 호출자용으로 두세요.

### 액션이 아니라 웹훅에서 구독자를 MailerLite에 추가하는 이유는 무엇인가요?

이유는 두 가지입니다. 액션은 스팸에도 실제 가입과 같은 성공 응답을 받으므로 봇까지 추가하게 됩니다. 그리고 액션 안에서 MailerLite 오류가 나면 Ikiform이 이미 저장한 가입에 실패가 표시되지만, 실패한 웹훅 전달은 재시도됩니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [뉴스레터 가입 폼](https://ikiform.com/ko/guides/newsletter-signup.md): 직접 작성한 마크업으로 뉴스레터 가입 폼을 만들고, 동의를 기록하고, 봇 가입을 막고, 각 구독자를 Kit, MailerLite, beehiiv 등 어떤 도구로든 넘깁니다.
- [MailerLite](https://ikiform.com/ko/guides/newsletter-signup/mailerlite.md): 내 가입 폼을 MailerLite에 연결합니다. 릴레이가 새 독자를 더블 옵트인을 위해 미확인으로 추가하고, 구독자에게 두 번 확인을 요청하지 않습니다.
- [더블 옵트인](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md): Ikiform은 확인 이메일을 보내지 않으므로 더블 옵트인은 이메일 도구에서 실행됩니다. 릴레이가 각 가입을 미확인으로 추가하고 도구가 링크를 보냅니다.
- [TanStack Start 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/tanstack-start.md): TanStack Start 뉴스레터 폼입니다. 페이지 내 가입용 서버 함수, JavaScript 없는 경우를 위한 서버 라우트, 그리고 실제 가입을 beehiiv로 보내는 구성입니다.
- [정적 사이트](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에 연결합니다.
