뉴스레터 가입 폼6분 분량
React Router로 뉴스레터 가입 폼 만들기
React Router 푸터 뉴스레터 폼입니다. useFetcher가 JavaScript 없이도 동작하는 /newsletter 라우트로 POST하고, 리소스 라우트가 MailerLite로 넘깁니다.
액션을 자체 페이지가 있는 실제 /newsletter 라우트에 두고, useFetcher로 만든 푸터 폼에서 그곳으로 POST하세요. JavaScript가 있으면 fetcher가 백그라운드에서 제출하고 독자는 글에 머뭅니다. 없으면 같은 폼이 일반 POST가 되어 결과가 표시된 /newsletter 페이지로 이동합니다. 액션은 동의를 확인하고 가입을 폼 백엔드로 전달하며, 리소스 라우트가 서명된 웹훅을 MailerLite 구독자로 바꿉니다.
메인 가이드의 React Router 탭은 useFetcher로 리소스 라우트에 POST합니다. JavaScript가 로드된 뒤에는 동작합니다. 이 페이지는 그 전에 일어나는 일을 고치고, 가입이 페이지의 모든 loader를 다시 불러오지 않게 하며, MailerLite용 웹훅 라우트를 추가합니다. 제품 설정은 서버 측 문서 가이드에 있습니다.
액션에 페이지가 필요한 이유
React Router 문서에 따르면 fetcher.Form은 JavaScript가 로드될 때까지 일반 HTML 폼입니다. 그때 제출하면 폼의 action URL로 문서 POST가 일어나고, 브라우저는 그 라우트가 렌더링하는 것을 보여 줍니다. 리소스 라우트는 아무것도 렌더링하지 않고 데이터를 반환합니다. 그래서 일찍 클릭한 독자는 원본 JSON을 보게 됩니다. 액션에 기본 컴포넌트가 있는 라우트를 주면 같은 POST가 페이지에 도착합니다.
- 하이드레이션 전에는 fetcher.Form이 일반 폼이므로 브라우저가 /newsletter로 POST하고 그곳으로 이동합니다.
- /newsletter가 리소스 라우트라면 브라우저가 액션의 JSON을 페이지로 보여 줍니다.
- /newsletter에 기본 컴포넌트가 있다면 React Router가 액션을 실행한 뒤 결과를 actionData로 넣어 페이지를 렌더링합니다.
- JavaScript가 로드되면 두 버전 모두 같게 동작합니다. fetcher가 백그라운드에서 POST하고 독자는 그 자리에 머뭅니다.
프로젝트 설정
서버 렌더링을 켠 프레임워크 모드를 씁니다. react-router.config.ts의 ssr: true이며 기본값입니다.
app/
root.tsx
routes.ts
routes/
home.tsx
newsletter.tsx
api.ikiform.ts
components/newsletter-form.tsx
react-router.config.ts
.envimport { 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;IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789API keys에서 submissions:write 스코프로 Ikiform 키를 만들고, 폼의 Fields 탭에 필러의 필드 정의를 붙여 넣으세요.
만들기
/newsletter 라우트
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 없는 결과와 페이지 내 결과가 똑같아 보입니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.푸터 폼
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페이지 폼이 함께 화면에 있을 때 상태가 분리됩니다.
페이지 데이터를 다시 불러오지 않고 마운트하기
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 가이드의 릴레이를 쓰세요. 주소를 먼저 조회하므로 다시 가입한 확인된 독자가 상태를 유지합니다.
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 가이드처럼 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이 있는 터널로 개발 서버를 노출하세요.
작동 확인하기
react-router dev를 실행하고 글을 열어 푸터에서 구독하세요. 받은편지함을 확인하라는 문구가 나타나고 URL은 바뀌지 않습니다.- 네트워크 탭을 열고 새 페이지에서 다시 구독하세요.
/newsletter로 가는 요청 하나만 있고 그 뒤에 loader 요청이 없습니다. - JavaScript를 끄고 구독하세요. JSON이 아니라 같은 문구가 있는
/newsletter로 이동합니다. - 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
- 웹훅을 추가했다면 Webhooks에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께
succeeded로 표시됩니다. - 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 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
질문
네. fetcher.Form은 일반 HTML 폼을 렌더링하므로 하이드레이션 전에는 액션 URL로 POST하고 이동합니다. 그 URL을 컴포넌트가 있는 라우트로 만드세요. 그렇지 않으면 독자는 액션의 원본 데이터를 보게 됩니다.
사람이 제출하는 폼이라면 아닙니다. JavaScript 없는 POST가 페이지를 보여 주도록 기본 컴포넌트가 있는 라우트를 쓰세요. 리소스 라우트는 웹훅처럼 브라우저가 아닌 호출자용으로 두세요.
이유는 두 가지입니다. 액션은 스팸에도 실제 가입과 같은 성공 응답을 받으므로 봇까지 추가하게 됩니다. 그리고 액션 안에서 MailerLite 오류가 나면 Ikiform이 이미 저장한 가입에 실패가 표시되지만, 실패한 웹훅 전달은 재시도됩니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.