빌더 폼 한계 넘어서기5분 분량
Netlify Forms 한도: 제출 100건에서 일어나는 일과 해결 방법
레거시 Netlify 플랜은 사이트별 폼 제출을 계량해 자동으로 업그레이드하고, 크레딧 플랜은 사이트를 일시 중지할 수 있으니 폼을 백엔드로 옮기세요.
Netlify의 레거시 플랜에서는 사이트마다 월 100건의 폼 제출이 포함되며, 이를 넘으면 사이트가 자동으로 유료 Forms 등급으로 올라갑니다. 2025년 9월 이후 생성된 계정의 크레딧 기반 플랜에서는 폼이 무료지만, 크레딧이 떨어진 무료 팀은 폼을 포함한 모든 사이트가 일시 중지됩니다. 폼을 Ikiform으로 옮기려면 일반 HTML이든 어떤 프레임워크든 Netlify 속성을 제거하고 action을 설정하면 됩니다.
Netlify에서 다른 점
Netlify는 사이트 빌더가 아니라 폼이 내장된 호스트입니다. 이 점이 한도와 해결 방법 모두를 결정합니다. 어떤 한도가 적용되는지는 계정 생성 시점에 따라 다릅니다.
| 계정 | Netlify 문서의 설명 | 제출이 많은 폼에 주는 의미 |
|---|---|---|
| 레거시 플랜, 2025년 9월 4일 이전 생성 | Forms Level 0은 사이트당 월 100건의 제출과 10 MB의 업로드를 포함합니다. 이를 넘으면 사이트가 1,000건의 Forms Level 1로 올라가고 결제 주기 말에 청구됩니다. 그 이상은 추가 사용량 패키지가 500건씩 더합니다 | 스팸이 몰려오면 아무도 결정하지 않았는데 사이트가 유료 등급으로 올라갈 수 있습니다. 무료 레거시 등급에서는 한도에 도달하면 계정의 모든 사이트가 일시 중지됩니다 |
| 크레딧 기반 플랜, 그 이후 생성 | 폼 제출은 등급 없이 무료이고 무제한입니다 | 폼 자체에는 비용이 들지 않습니다. 하지만 무료 팀이 월 크레딧을 다 쓰면 Netlify가 모든 프로젝트를 일시 중지하고, 일시 중지된 프로젝트는 폼 제출을 받지 않습니다 |
Netlify 자체 문서는 레거시 업그레이드가 자동이며 사용량이 하위 등급보다 높은 동안에는 내려갈 수 없다고 분명히 밝힙니다. 결정하기 전에 Netlify 대시보드에서 팀의 플랜 유형과 사이트의 폼 사용량을 확인하세요.
사람들이 옮기는 두 번째 이유는 숫자와 무관합니다. Netlify Forms는 배포된 HTML을 읽는 방식으로 동작합니다.
- 폼은 배포 시점에 감지됩니다. React, Vue, Svelte가 렌더링하는 폼은 그 HTML에 존재하지 않으므로, 빌드가 찾을 수 있도록 숨겨진 정적 사본을 두고
form-name필드와 함께 그곳으로 POST합니다. - Next.js에서는 OpenNext 어댑터가 그 사본을
public/의 정적 파일로 요구하며, POST는 Next.js 페이지가 아니라 그 정적 파일로 가야 합니다. 사본 없이 React 코드에data-netlify가 있으면 빌드가 실패합니다. - 제출은 urlencoded나 multipart여야 합니다. JSON은 받지 않습니다.
- 폼은 사이트가 Netlify에 배포되어 있는 동안에만 동작합니다. 호스트를 옮기면 폼도 옮겨야 합니다.
- 레거시 플랜에서 사이트는 100건이 포함된 Forms Level 0으로 달을 시작합니다.
- 101번째 제출이 도착합니다. Netlify 사용량 문서는 사이트의 사용량을 기준으로 설명하므로, 스팸이 몰려와 한도를 넘길 수도 있습니다.
- Netlify가 사이트를 1,000건의 Forms Level 1로 올리고 주기 말에 청구합니다.
- Level 1을 넘으면 추가 사용량 패키지가 500건씩 더합니다.
- 폼이 Ikiform으로 POST하면 페이지는 여전히 Netlify에 있지만 POST는 Ikiform 엔드포인트로 갑니다.
- Netlify Forms는 POST를 받지 않으므로 집계하거나 업그레이드할 것이 없습니다.
스택별로 폼 옮기기
코드를 바꾸기 전에 Netlify 폼 이름마다 Ikiform 폼을 하나씩 만들고 Honeypot field를 bot-field로 설정하세요. 그러면 기존 숨겨진 입력이 이름 변경 없이 허니팟으로 계속 동작합니다. 스택을 고르세요.
Netlify의 일반 HTML은 변경이 가장 적습니다. Netlify 속성과 form-name 입력을 삭제하고, action이 엔드포인트를 가리키게 하고, 감사 페이지 경로를 전체 URL로 _redirect에 옮기세요. bot-field 입력은 허니팟으로 남습니다.
<form name="contact" method="POST" action="/thanks" data-netlify="true" netlify-honeypot="bot-field">
<input type="hidden" name="form-name" value="contact" />
<p hidden><input name="bot-field" /></p>
<label>Email <input type="email" name="email" required /></label>
<label>Topic
<select name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
</label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
</form>Netlify에서 Next.js 폼은 컴포넌트가 POST하는 public/__forms.html 같은 public/의 정적 사본을 통해서만 Netlify Forms와 동작했습니다. 그 파일을 삭제하고 NETLIFY_NEXT_VERIFY_FORMS 설정을 제거한 뒤 컴포넌트에서 Ikiform으로 POST하세요. action 덕분에 폼은 JavaScript 없이도 POST합니다.
"use client";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const [state, setState] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setState("sending");
setError("");
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget),
});
const result = await response.json();
if (result.ok) return setState("sent");
setError(result.error.fields?.message?.[0] ?? result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't send. Check your connection and try again.");
}
setState("idle");
}
if (state === "sent") return <p role="status">Thanks. We read every message and reply within a day.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<input name="bot-field" type="text" tabIndex={-1} autoComplete="off" hidden />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="question">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{error ? <p role="alert">{error}</p> : null}
<button disabled={state === "sending"}>{state === "sending" ? "Sending" : "Send"}</button>
</form>
);
}정적 Astro 페이지에는 JavaScript가 전혀 필요 없습니다. 폼에서 data-netlify를 제거하고 엔드포인트를 action으로 설정한 뒤, astro.config.mjs의 site로 리디렉션을 만드세요. 페이지는 사전 렌더링 상태로 둘 수 있습니다.
---
const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const thanksUrl = new URL("/thanks", Astro.site).href;
---
<form method="post" action={endpoint}>
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<input type="hidden" name="_redirect" value={thanksUrl} />
<button>Send</button>
</form>Netlify의 TanStack Start 앱은 React로 폼을 렌더링하므로 Netlify의 배포 시 감지가 이를 볼 수 없었습니다. 대신 서버 함수로 POST하고 숨겨진 정적 사본을 없애세요. Netlify에서 사이트의 환경 변수에 IKIFORM_SECRET_KEY를 설정하세요. 함수가 브라우저의 Origin 헤더를 전달하므로, 워크스페이스에 플랜이 생기기 전에는 localhost의 POST가 무료 테스트로 집계됩니다.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
email: String(data.get("email") ?? "").trim(),
topic: String(data.get("topic") ?? "question"),
message: String(data.get("message") ?? "").trim(),
"bot-field": String(data.get("bot-field") ?? ""),
};
})
.handler(async ({ data }) => {
const origin = getRequestHeader("origin");
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(data),
});
const result = await response.json();
return result.ok ? { sent: true, error: "" } : { sent: false, error: String(result.error.message) };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [state, setState] = useState({ sent: false, error: "" });
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setState(await send({ data: new FormData(event.currentTarget) }));
} catch {
setState({ sent: false, error: "Couldn't send. Try again." });
} finally {
setPending(false);
}
}
if (state.sent) return <p role="status">Thanks. We read every message and reply within a day.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<input name="bot-field" type="text" tabIndex={-1} autoComplete="off" hidden />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="question">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send"}</button>
</form>
);
}프레임워크 모드의 React Router에서는 라우트의 action이 POST를 전달합니다. FormData가 그대로 전달되므로 bot-field가 Ikiform의 허니팟 확인에 도달하고, 브라우저의 Origin 헤더도 함께 가므로 localhost의 POST가 테스트로 집계됩니다. Netlify용으로 둔 정적 사본을 제거하세요.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const origin = request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: form,
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
return data({ sent: false, error: String(result.error.message) }, { status: response.status });
}
export default function Contact({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
if (actionData?.sent) return <p role="status">Thanks. We read every message and reply within a day.</p>;
return (
<Form method="post">
<input name="bot-field" type="text" tabIndex={-1} autoComplete="off" hidden />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="question">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{actionData?.error ? <p role="alert">{actionData.error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send"}</button>
</Form>
);
}Netlify의 SvelteKit 페이지는 자체 폼 액션 없이 브라우저에서 바로 POST할 수 있습니다. action 속성 덕분에 하이드레이션 전에도 폼이 동작합니다. Netlify용으로 추가한 폼의 사전 렌더링 사본은 삭제하세요.
<script lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
let sent = $state(false);
let sending = $state(false);
let error = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
sending = true;
error = "";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.currentTarget as HTMLFormElement),
});
const result = await response.json();
if (result.ok) sent = true;
else error = result.error.message;
} catch {
error = "Couldn't send. Check your connection and try again.";
}
sending = false;
}
</script>
{#if sent}
<p role="status">Thanks. We read every message and reply within a day.</p>
{:else}
<form method="post" action={ENDPOINT} onsubmit={send}>
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
{#if error}<p role="alert">{error}</p>{/if}
<button disabled={sending}>{sending ? "Sending" : "Send"}</button>
</form>
{/if}Nuxt에서는 컴포넌트가 브라우저에서 $fetch로 POST하므로 배포할 서버 라우트가 없습니다. $fetch는 2xx가 아닌 응답에 예외를 던지며, Ikiform의 오류 봉투는 오류의 data에 있습니다. public/의 숨겨진 정적 폼과 form-name 필드를 제거하세요.
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sent = ref(false);
const sending = ref(false);
const error = ref("");
async function send(event: Event) {
sending.value = true;
error.value = "";
try {
await $fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
sent.value = true;
} catch (failure) {
const data = (failure as { data?: { error?: { message?: string } } }).data;
error.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. We read every message and reply within a day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send" }}</button>
</form>
</template>Netlify의 React 싱글 페이지 앱은 form-name 필드와 함께 urlencoded 데이터를 /로 POST했고, 감지를 위해 index.html에 정적 사본이 필요했습니다. 이제는 FormData를 엔드포인트로 POST합니다. 요청만 바뀝니다.
const body = new URLSearchParams(new FormData(event.currentTarget) as unknown as Record<string, string>);
body.set("form-name", "contact");
await fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});Netlify로 POST하는 것이 없어지면 index.html에서 숨겨진 <form netlify>를 삭제하세요.
Vue 싱글 파일 컴포넌트에서 같은 브라우저 측 POST입니다. Netlify가 감지하도록 index.html에 둔 숨겨진 폼을 제거하세요.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const state = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send(event: Event) {
state.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const result = await response.json();
if (result.ok) {
state.value = "sent";
return;
}
error.value = result.error.message;
} catch {
error.value = "Couldn't send. Check your connection and try again.";
}
state.value = "idle";
}
</script>
<template>
<p v-if="state === 'sent'" role="status">Thanks. We read every message and reply within a day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="state === 'sending'">{{ state === "sending" ? "Sending" : "Send" }}</button>
</form>
</template>IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.모든 버전이 Netlify에서 쓰던 필드 이름을 유지하므로 알림 이메일과 CSV 열이 익숙하게 보입니다. Netlify Forms 비교에 data-netlify-recaptcha와 파일 입력을 포함한 전체 속성 대응표가 있습니다.
배포 미리보기와 허용 도메인
Netlify는 풀 리퀘스트마다 deploy-preview-42--your-site.netlify.app 같은 별도 주소를 줍니다. 이는 your-site.netlify.app의 서브도메인이 아니므로, 사이트 주소를 Allowed domains에 등록해도 미리보기는 포함되지 않고 미리보기의 POST는 origin_mismatch로 스팸에 들어갑니다.
- 프로덕션용으로
your-site.netlify.app과 커스텀 도메인을 추가하세요. - 테스트하는 동안 특정 미리보기 호스트를 추가했다가 나중에 제거하세요.
netlify.app만 단독으로 추가하지 마세요. Netlify의 모든 사이트가 내 폼으로 POST할 수 있게 됩니다.- 또는 환경 변수로 미리보기가 허용 도메인이 없는 두 번째 Ikiform 폼을 가리키게 하고, 프로덕션은 엄격하게 유지하세요.
제출을 잃지 않고 전환하기
- 사이트의 Forms 섹션에서 각 폼의 과거 제출을 CSV로 다운로드하세요. Ikiform에는 가져오기 기능이 없습니다.
- 새 폼을 미리보기에 배포하고 테스트를 보내세요. Ikiform 대시보드에서 필드 이름을 확인하세요.
- 프로덕션에 병합하고 실제 도메인에서 테스트를 한 번 더 보내세요.
- Netlify 발신 웹훅을 Ikiform 웹훅으로 바꾸세요. Netlify는 JWS 토큰으로 서명하고 Ikiform은 Standard Webhooks 헤더를 쓰므로 검증 코드도 바뀝니다.
- 며칠 동안 Netlify에 도달한 POST가 없으면 사이트의 Forms 설정에서 폼 감지를 끄세요.
422 files_not_supported를 받습니다. 폼에 파일이 필요하다면 그 폼 하나만 Netlify Forms에 두고 나머지를 옮기세요.Netlify에서 동작 확인하기
- 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발하는 동안에는
localhost도 추가하세요. 비워 두면 어디서든 POST를 받습니다. - 미리보기를 배포하고 그 호스트를 추가한 뒤 거기서 폼을 제출하세요.
- 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
form-name과bot-field없이 필드가email,topic,message인지 확인하세요.- Netlify에서 사이트의 Forms 탭을 열어 테스트가 없는지 확인하세요.
bot-field를 채워서 한 번 POST하세요. 스팸으로 저장되고 아무것도 보내지 않습니다.- 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에
email필드가 있으면 Reply는 제출자에게 갑니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
Ikiform에 form-name 필드가 나타남 | 숨겨진 form-name 입력이 아직 폼에 있거나 코드가 여전히 설정합니다. 제거하세요. Ikiform은 URL로 폼을 식별합니다. |
| 폼을 언급하며 Next.js 빌드가 실패함 | React 코드에 netlify나 data-netlify 속성이 아직 있습니다. public/__forms.html과 함께 제거하세요. |
| 미리보기 POST가 스팸으로 들어감 | 미리보기 호스트가 Allowed domains에 없습니다. 테스트하는 동안 추가하거나 미리보기용 두 번째 폼을 쓰세요. |
| 실제 POST까지 모두 스팸으로 표시됨 | 입력은 bot-field인데 Honeypot field가 아직 _gotcha이거나, 그 반대입니다. 둘을 맞추세요. |
422 files_not_supported | 폼에 파일이 선택된 <input type="file">이 있습니다. 파일 업로드는 아직 지원하지 않습니다. 입력을 지우거나 대신 링크를 요청하세요. |
429 rate_limited | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. Retry-After만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
질문
레거시 플랜에서는 Netlify가 사이트를 월 1,000건이 포함된 Forms Level 1로 올리고 결제 주기 말에 청구합니다. 무료 레거시 등급에서는 한도에 도달하면 업그레이드할 때까지 계정의 모든 사이트가 일시 중지됩니다.
Netlify 문서에 따르면 크레딧 기반 플랜에서 폼 제출은 무료이고 무제한입니다. 문제는 사이트 자체입니다. 무료 팀이 월 크레딧을 다 쓰면 프로젝트가 일시 중지되고 폼 제출을 받지 않습니다.
네. Ikiform은 페이지가 어디에 호스팅되든 상관하지 않습니다. 폼에서 Netlify 속성을 제거하고 action을 Ikiform 엔드포인트로 설정하면 Netlify Forms는 더 이상 이를 보지 않습니다.
아니요. 그 사본은 Netlify 배포 단계가 폼을 찾기 위해서만 존재합니다. Ikiform은 URL로 POST를 받으므로 사본을 삭제하고 컴포넌트에서 POST하세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.