가이드11분 분량
백엔드 없이 정적 사이트에 문의 폼 추가하기
서버 없이 정적 사이트에 작동하는 문의 폼을 추가하세요. POST가 가는 곳, 스팸, 이메일 답장, 호스트와 프레임워크별 코드까지 다룹니다.
정적 호스트는 파일을 제공할 뿐 폼 POST는 거부하며, 보통 405나 404로 응답합니다. 그래서 폼은 다른 곳으로 POST해야 합니다. 폼의 action을 Ikiform 같은 폼 백엔드로 지정하세요. 메시지를 저장하고, 스팸을 거르고, 보낸 사람을 회신 주소로 설정해 이메일로 알려 줍니다. 사이트는 정적 그대로입니다. 서버 코드도, 페이지 속 시크릿 키도 없고, 배포 방식도 바뀌지 않습니다.
정적 사이트는 호스트가 파일로 내주는 HTML, CSS, JavaScript 폴더입니다. 그래서 빠르고, 저렴하고, 잘 망가지지 않습니다. 그리고 바로 그 이유로 문의 폼이 가장 먼저 작동을 멈춥니다. 폼 제출은 누군가 받아야 하는 요청인데, 정적 호스트에는 이를 기다리는 것이 없습니다.
정적 호스트에서 폼이 실패하는 이유
action이 없는 <form method="post">는 자신이 있는 페이지로 POST합니다. 정적 호스트에서 그 페이지는 파일이고, 파일은 GET과 HEAD에만 응답합니다. 2026-10-25에 주요 호스트마다 실제 정적 페이지로 POST를 보내 봤습니다. 아무 곳도 저장하지 않았습니다.
| 호스트 | 정적 페이지로 보낸 POST의 결과 |
|---|---|
| GitHub Pages | 작은 HTML 페이지로 된 405 Not Allowed |
| Vercel | 빈 본문의 405 |
| Netlify | 404. 사이트에 404 페이지가 있으면 그 페이지를 보여 줍니다. 단, 배포 시점에 Netlify Forms가 그 폼을 감지한 경우는 예외입니다 |
| Cloudflare Pages | 빈 본문의 405 |
| Amazon S3 웹사이트 엔드포인트 | 405 Method Not Allowed, 코드 MethodNotAllowed |
해결책은 어디서나 같습니다. POST를 받아 주는 URL로 보내면 됩니다. 그 URL은 직접 작성해 호스팅하는 코드일 수도, 호스트의 기능일 수도, 폼 백엔드일 수도 있습니다. 이 가이드의 나머지 부분은 사이트를 정적으로 유지할 수 있는 폼 백엔드를 사용합니다.
- action이 없거나 action이 내 사이트의 경로를 가리키면, 브라우저는 정적 호스트로 POST합니다.
- 호스트에는 POST를 처리할 코드가 없으므로 405나 404로 응답하고, 메시지는 사라집니다.
- action이 Ikiform 엔드포인트를 가리키면, POST는 이를 받아 주는 서버로 갑니다.
- Ikiform이 메시지의 스팸 여부를 검사하고, 저장하고, 이메일로 알려 줍니다.
- Ikiform이 303 리디렉션으로 응답하면, 브라우저가 내 사이트의 감사 페이지를 엽니다.
각 메시지에 일어나는 일
Ikiform은 폼이 POST하는 엔드포인트입니다. 페이지를 호스팅하거나 폼을 렌더링하지 않으며, 둘 다 여러분이 그대로 가집니다. POST마다 스팸 검사를 하고, 메시지를 저장하고, 여러분이 읽는 곳으로 전달합니다.
- 방문자가 문의 폼을 작성하면 폼이 Ikiform 엔드포인트로 POST합니다.
- Ikiform이 허니팟, 레이트 리밋, 스팸 점수를 검사한 뒤 메시지를 저장합니다.
- 알림 수신자마다 회신 주소가 보낸 사람의 주소로 설정된 이메일을 받습니다.
- 메시지가 대시보드에 나타나며, 거기서 검색하고, 스팸을 복원하고, CSV로 내보낼 수 있습니다.
- 웹훅을 추가하면 Ikiform이 서명된 사본을 Slack, Discord, 자동화 도구, 또는 여러분의 코드로 보냅니다.
정적 사이트의 폼을 받는 다섯 가지 방법
| 방식 | 장점 | 단점 |
|---|---|---|
mailto: 링크 또는 action="mailto:..." | 설정이 전혀 필요 없습니다. 방문자가 적은 개인 페이지라면 괜찮습니다. | 방문자에게 메일 앱이 설정되어 있다면 그 앱을 엽니다. 아무것도 저장하거나 거르지 않으며, 여러분의 주소가 HTML에 그대로 노출되어 수집 봇의 표적이 됩니다. |
| 호스트의 폼 기능, 예: Netlify Forms | 속성 하나면 되고, 다른 계정이 필요 없으며, 보통 호스팅 플랜에 포함됩니다. | 그 호스트에서만 작동합니다. 호스트를 옮기면 폼을 다시 만들어야 하고, JavaScript로 렌더링하는 폼은 우회책이 필요합니다. |
| 직접 작성하는 서버리스 함수 | 검증, 저장, 보내는 이메일을 완전히 제어할 수 있습니다. | 이제 백엔드를 떠안게 됩니다. 이메일 API 키, 스팸 필터링, 레이트 리밋, 재시도, 로그까지요. 함수 형식도 호스트마다 다릅니다. |
| 호스팅 폼 임베드, 예: Google Forms, Tally, Typeform | 코드가 필요 없습니다. 길거나 여러 단계로 된 폼에 적합합니다. | 다른 사이트로 가는 iframe이나 링크이며, 그 서비스의 디자인과 스크립트가 여러분의 페이지에 들어옵니다. |
| 폼 백엔드, 예: Ikiform, Formspree, Basin, Web3Forms | 어떤 호스트에서든 직접 작성한 HTML을 씁니다. 서버 없이 저장, 스팸 필터링, 이메일을 처리합니다. | 계정과 청구서가 하나 더 늘고, 폼 데이터가 그 서비스에 보관됩니다. |
Netlify를 쓰고 있고 옮길 계획이 없다면, 수고 면에서 Netlify Forms를 이기기 어렵습니다. 백엔드를 직접 작성하고 싶다면 함수는 주말 프로젝트로 괜찮습니다. 호스트나 프레임워크를 바꿔도 폼이 계속 작동하길 원한다면 폼 백엔드를 쓰세요. 비교에서 백엔드들을 나란히 볼 수 있습니다.
폼
아래 예제는 모두 브라우저에서 POST합니다. 정적 배포에는 POST를 보낼 서버가 없기 때문입니다. 스택을 고르세요. 선택한 스택은 이 가이드들의 모든 코드 예제에 그대로 적용됩니다. 정적 사이트 생성기는 별도의 설정 페이지가 있습니다: Jekyll, Hugo, Eleventy, Gatsby.
직접 작성한 사이트라면 이것이 연동의 전부입니다. 폼이 일반적인 방식으로 POST하므로 JavaScript를 꺼도 작동하며, 전송에 성공하면 브라우저가 thanks.html로 이동합니다.
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" maxlength="120" required />
<p class="field-error" data-error-for="name" hidden></p>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p class="field-error" data-error-for="email" hidden></p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" maxlength="5000" required></textarea>
<p class="field-error" data-error-for="message" hidden></p>
<input type="hidden" name="_redirect" value="https://example.com/thanks.html" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button type="submit">Send message</button>
<p id="contact-status" role="status"></p>
</form>
<p id="contact-sent" role="status" hidden>Thanks. Your message is on its way, and I'll reply by email.</p>
<script src="/contact.js" defer></script>스크립트는 선택 사항입니다. 방문자를 페이지에 머물게 하고, 필드 오류를 각 입력 아래에 표시하며, 실패해도 입력한 메시지를 그대로 남겨 둡니다.
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");
const done = document.querySelector("#contact-sent");
const button = form.querySelector("button");
function showErrors(fields = {}) {
for (const slot of form.querySelectorAll("[data-error-for]")) {
const name = slot.dataset.errorFor;
const message = fields[name]?.[0] ?? "";
slot.textContent = message;
slot.hidden = !message;
form.elements[name]?.setAttribute("aria-invalid", message ? "true" : "false");
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.hidden = true;
done.hidden = false;
return;
}
showErrors(result.error.fields);
status.textContent = result.error.message;
} catch {
status.textContent = "Couldn't send. Check your connection and try again.";
}
button.disabled = false;
});output: "export"를 쓰면 next build가 일반 파일을 out/에 씁니다. 정적 내보내기에서는 요청을 읽는 Server Actions와 Route Handlers를 지원하지 않으므로, 폼은 대신 브라우저에서 POST합니다.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;"use client";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
type Field = "name" | "email" | "message";
type Errors = Partial<Record<Field, string>>;
export function ContactForm() {
const [sentTo, setSentTo] = useState("");
const [sending, setSending] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [notice, setNotice] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setSending(true);
setNotice("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setSentTo(String(body.get("email")));
return;
}
const fields = result.error.fields ?? {};
setErrors({ name: fields.name?.[0], email: fields.email?.[0], message: fields.message?.[0] });
setNotice(result.error.message);
} catch {
setNotice("Couldn't send. Check your connection and try again.");
} finally {
setSending(false);
}
}
if (sentTo) return <p role="status">Thanks. I'll reply to {sentTo}.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" maxLength={120} required aria-invalid={!!errors.name} />
{errors.name ? <p>{errors.name}</p> : null}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required aria-invalid={!!errors.email} />
{errors.email ? <p>{errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required aria-invalid={!!errors.message} />
{errors.message ? <p>{errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{notice ? <p role="alert">{notice}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}app/contact/page.tsx에서 <ContactForm />을 렌더링하세요. 폼이 action을 유지하므로 JavaScript 번들이 로드되기 전에도 POST합니다.
Astro는 기본적으로 모든 페이지를 사전 렌더링하며, 정적 호스트에는 바로 이것이 필요합니다. 여기서는 Astro Actions를 쓰지 마세요. 폼을 액션으로 POST하려면 온디맨드 렌더링과 어댑터가 필요합니다. Ikiform으로 POST하고, 번들된 <script>가 방문자를 페이지에 머물게 하세요.
---
import Layout from "../layouts/Layout.astro";
const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
---
<Layout title="Contact">
<form id="contact" action={endpoint} method="post">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required></textarea>
<input type="hidden" name="_redirect" value={new URL("/thanks/", Astro.site).href} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send message</button>
<output id="contact-status" role="status"></output>
</form>
</Layout>
<script>
const form = document.querySelector<HTMLFormElement>("#contact")!;
const status = document.querySelector<HTMLOutputElement>("#contact-status")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
status.value = "Sending";
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) {
form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Thanks. Your message is on its way, and I'll reply by email." }));
return;
}
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const first = Object.entries(fields)[0];
status.value = first ? `${first[0]}: ${first[1][0]}` : (result?.error?.message ?? "Couldn't send. Check your connection and try again.");
});
</script>Astro.site는 astro.config.mjs의 site에서 옵니다. _redirect 값이 Ikiform이 따를 수 있는 전체 URL이 되도록 https: 주소로 설정하세요.
SPA 모드는 어떤 정적 호스트에서든 제공할 수 있는 클라이언트 렌더링 셸을 빌드합니다. 서버 함수는 여전히 서버가 필요하므로 정적 배포에서는 실행할 수 없습니다. 이 라우트는 브라우저에서 POST하고 성공하면 /thanks로 이동합니다.
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tanstackStart({
spa: {
enabled: true,
},
}),
viteReact(),
],
});import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const navigate = useNavigate();
const [problems, setProblems] = useState<string[]>([]);
const [sending, setSending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSending(true);
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) {
await navigate({ to: "/thanks" });
return;
}
const fields: Record<string, string[]> = result.error.fields ?? {};
setProblems(Object.keys(fields).length ? Object.values(fields).map((list) => list[0]) : [result.error.message]);
} catch {
setProblems(["Couldn't send. Check your connection and try again."]);
}
setSending(false);
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
{problems.length ? (
<ul role="alert">
{problems.map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
) : null}
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}ssr: false면 서버 빌드가 없으므로 라우트에 서버 action을 둘 수 없습니다. 대신 clientAction이 브라우저에서 실행되어 <Form>의 POST를 Ikiform으로 전달하고, 성공하면 리디렉션합니다.
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;import { Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export async function clientAction({ request }: Route.ClientActionArgs) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: await request.formData(),
});
const result = await response.json();
if (result.ok) return redirect("/thanks");
return {
fields: (result.error.fields ?? {}) as Record<string, string[]>,
message: String(result.error.message),
};
}
export default function Contact({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
const error = (name: string) => actionData?.fields[name]?.[0];
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{error("name") ? <p>{error("name")}</p> : null}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{error("email") ? <p>{error("email")}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
{error("message") ? <p>{error("message")}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</Form>
);
}adapter-static과 prerender = true를 쓰면 모든 페이지가 파일이 됩니다. SvelteKit 폼 액션은 쓸 수 없습니다. 액션이 있는 페이지는 서버가 POST를 처리해야 하므로 사전 렌더링할 수 없기 때문입니다. 대신 컴포넌트가 fetch로 POST합니다.
export const prerender = true;<script lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
let phase = $state<"idle" | "sending" | "sent">("idle");
let errors = $state<Record<string, string[]>>({});
let notice = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
phase = "sending";
notice = "";
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) {
phase = "sent";
return;
}
errors = result.error.fields ?? {};
notice = result.error.message;
} catch {
notice = "Couldn't send. Check your connection and try again.";
}
phase = "idle";
}
</script>
{#if phase === "sent"}
<p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
{:else}
<form action={ENDPOINT} method="post" onsubmit={send}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required aria-invalid={!!errors.name} />
{#if errors.name}<p>{errors.name[0]}</p>{/if}
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required aria-invalid={!!errors.email} />
{#if errors.email}<p>{errors.email[0]}</p>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required aria-invalid={!!errors.message}></textarea>
{#if errors.message}<p>{errors.message[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if notice}<p role="alert">{notice}</p>{/if}
<button disabled={phase === "sending"}>{phase === "sending" ? "Sending" : "Send message"}</button>
</form>
{/if}nuxt generate는 사전 렌더링한 페이지를 .output/public에 씁니다. server/api의 라우트는 정적 호스트에 존재하지 않으므로, 컴포넌트가 $fetch로 Ikiform을 직접 호출하고 성공하면 /thanks로 이동합니다.
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const form = reactive({ name: "", email: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const notice = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
notice.value = "";
try {
await $fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: form });
await navigateTo("/thanks");
} catch (failure) {
const data = (failure as { data?: { error?: { message?: string; fields?: Record<string, string[]> } } }).data;
errors.value = data?.error?.fields ?? {};
notice.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<form :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="form.name" name="name" autocomplete="name" required />
<p v-if="errors.name">{{ errors.name[0] }}</p>
<label for="email">Email</label>
<input id="email" v-model="form.email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" v-model="form.message" name="message" rows="6" minlength="10" required />
<p v-if="errors.message">{{ errors.message[0] }}</p>
<input v-model="form._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="notice" role="alert">{{ notice }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>정적 호스트에 올린 Vite 싱글 페이지 앱입니다. 엔드포인트를 VITE_ 변수에 두어 프리뷰 빌드와 프로덕션 빌드가 서로 다른 폼을 가리킬 수 있게 합니다. 어느 쪽이든 번들에 들어가지만 비밀이 아니므로 괜찮습니다.
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxximport { type FormEvent, useState } from "react";
const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "failed"; lines: string[] };
export function ContactForm() {
const [status, setStatus] = useState<Status>({ kind: "idle" });
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus({ kind: "sending" });
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) return setStatus({ kind: "sent" });
const fields: Record<string, string[]> = result.error.fields ?? {};
const lines = Object.entries(fields).map(([name, list]) => `${name}: ${list[0]}`);
setStatus({ kind: "failed", lines: lines.length ? lines : [result.error.message] });
} catch {
setStatus({ kind: "failed", lines: ["Couldn't send. Check your connection and try again."] });
}
}
if (status.kind === "sent") return <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} minLength={10} required />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{status.kind === "failed" ? (
<div role="alert">
{status.lines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
<button disabled={status.kind === "sending"}>{status.kind === "sending" ? "Sending" : "Send message"}</button>
</form>
);
}같은 브라우저 POST를 Vue 싱글 파일 컴포넌트로 작성했으며, 같은 VITE_ 변수에서 엔드포인트를 읽습니다. 첫 번째 오류만 버튼 옆에 표시합니다.
<script setup lang="ts">
import { computed, ref } from "vue";
const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;
const sending = ref(false);
const sent = ref(false);
const fields = ref<Record<string, string[]>>({});
const notice = ref("");
const firstError = computed(() => Object.values(fields.value)[0]?.[0] ?? notice.value);
async function send(event: SubmitEvent) {
sending.value = true;
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const result = await response.json();
sent.value = result.ok;
fields.value = result.ok ? {} : (result.error.fields ?? {});
notice.value = result.ok ? "" : result.error.message;
} catch {
notice.value = "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required :aria-invalid="!!fields.name" />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required :aria-invalid="!!fields.email" />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10" required :aria-invalid="!!fields.message" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="firstError" role="alert">{{ firstError }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>frm_xxxxxxxxxxxxxxxx를 대시보드의 폼 ID로 바꾸세요. ID는 원래 공개되는 값입니다. 배포하는 순간 HTML에 들어갑니다. 정적 사이트에는 절대 시크릿 API 키를 넣지 마세요.- 이메일 필드의 이름은
email로 유지하세요. Ikiform은 이 값을 알림 이메일의 회신 주소로 설정하고, 일회용 이메일 도메인인지 검사합니다. - 숨겨진
_gotcha입력은 허니팟입니다. 사람에게는 보이지 않고, 모든 입력을 채우는 봇은 스스로 스팸으로 표시됩니다. - JavaScript 버전은
Accept: application/json을 보내므로, Ikiform은 성공하면201로, 실패하면 각 필드와 문제를 알려 주는error.fields객체와 함께422로 응답합니다.
프레임워크가 프로덕션에서 실제로 서버에서 실행된다면, 대신 서버에서 POST해 엔드포인트를 페이지 밖에 둘 수 있습니다. Next.js, SvelteKit, Nuxt 가이드에 서버 버전이 있고, 시크릿 키는 서버에서 보내기에서 다룹니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.문의 폼의 필드
이름, 이메일, 메시지면 거의 모든 문의 폼을 감당합니다. 메시지 종류에 따라 답하는 사람이 다르다면 주제 선택 항목을 추가할 만합니다. 아래 폼을 써 보세요. 브라우저에서 Ikiform의 검증기를 실행해 저장될 내용이나 돌려받을 422를 보여 줍니다.
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
| 필드 | 이유 | 주의할 점 |
|---|---|---|
name | 답장에서 상대를 부르는 이름 | 이름 필드에 링크가 있으면 강한 스팸 신호이므로 ID는 name으로 유지하세요 |
email | 답장이 갈 곳 | 필수. type="email"로 휴대폰에서 알맞은 키보드가 뜹니다 |
topic | 영업과 지원을 알맞은 사람에게 보내기 | 선택 항목은 나열된 값만 받으므로 안전하게 필터링할 수 있습니다 |
message | 폼의 핵심 | 최소 길이를 10으로 두면 한 단어짜리 POST를 막습니다 |
전화번호, 회사 규모 등 첫 답장에 쓰지 않을 항목은 빼세요. 필드를 하나 추가할 때마다 받는 메시지가 줄어듭니다. 필드 정의가 없으면 Ikiform은 폼이 보내는 이름 있는 입력을 모두 저장하고, 정의가 있으면 맞지 않는 POST를 거부합니다.
422 files_not_supported를 받습니다.받은편지함에서 답장하기
알림 이메일은 폼마다 확인된 수신자 최대 10명에게 갑니다. 폼에 email 필드가 있으면 이메일의 회신 주소가 보낸 사람이 되므로, 메일 앱에서 Reply를 누르면 바로 그 사람에게 답장합니다. 대시보드에서 주소를 복사할 일이 없습니다.
알림은 폼마다 시간당 이메일 50통으로 제한되고, 그 뒤로는 시간별 요약 한 통으로 바뀌므로 스팸이 몰려와도 받은편지함이 묻히지 않습니다. 스팸으로 표시된 메시지는 이메일을 전혀 보내지 않습니다. _subject는 알림 이메일의 제목을 바꾸지 않습니다. 이 값은 meta.subject로 웹훅과 API에 전달됩니다.
메시지를 이메일 대신, 또는 이메일과 함께 팀 채팅으로 받고 싶나요? 웹훅을 추가하고 Slack, Discord, Telegram 가이드를 따라 하세요.
방문자가 전송 후 보는 화면
일반 HTML POST는 리디렉션으로 끝납니다. Ikiform은 브라우저 POST가 성공하면 303 See Other로 응답하고, 브라우저는 대상 페이지를 GET으로 불러옵니다. 이는 정적 호스트가 문제없이 제공할 수 있습니다.
- 브라우저가 폼 필드를 Ikiform 엔드포인트로 POST합니다.
- Ikiform이 메시지를 저장하고 Location 헤더와 함께 303 See Other로 응답합니다.
- 브라우저가 리디렉션을 따라 감사 페이지로 GET 요청을 보내고, 정적 호스트는 다른 파일처럼 이 페이지를 제공합니다.
Ikiform은 다음 순서로 대상을 고릅니다.
- 폼 설정의 Redirect after submit. 여러분의 팀만 설정할 수 있으므로 어떤
http:또는https:URL이든 쓸 수 있습니다. 사이트가 하나라면 가장 간단한 방법입니다. - 폼의
_redirect필드. 폼의 Allowed domains 중 하나에 있는https:URL일 때만 사용합니다. 폼 하나로 여러 사이트나 언어를 처리할 때 쓰세요. - 그 밖의 경우에는 Ikiform의 호스팅 감사 페이지. 허용된
https:페이지에서 POST했다면 여러분의 사이트로 돌아가는 링크가 있습니다.
감사 페이지는 실제 파일로 만들고 검색 결과에는 나오지 않게 하세요. 언제 답장할지 적어 두면 같은 사람에게서 두 번째 이메일이 오는 일을 줄일 수 있습니다.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Message sent</title>
</head>
<body>
<h1>Thanks, your message is on its way</h1>
<p>I read every message and reply within two working days, from the address on this site.</p>
<p><a href="/">Back to the home page</a></p>
</body>
</html>위의 JavaScript 버전은 리디렉션을 건너뜁니다. Accept: application/json이 있는 POST는 JSON을 돌려받고, 페이지가 직접 성공 메시지를 보여 줍니다.
정적 폼의 스팸
엔드포인트는 페이지 소스에 있고, 봇은 페이지 소스를 읽습니다. 공개 후 며칠 안에 스팸이 올 것이라고 생각하세요. Ikiform은 방어를 여러 겹으로 쌓으므로 아무 설정 없이 시작할 수 있습니다.
| 방어 | 막는 것 | 설정 |
|---|---|---|
| 허니팟 | 모든 입력을 채우는 봇 | 숨겨진 _gotcha 입력 |
| 스팸 점수 | 이름에 든 링크(+3), 많은 링크(+2), 알려진 스팸 문구(+2), 일회용 주소. 5점이 되면 메시지를 스팸으로 저장합니다 | 없음 |
| 레이트 리밋 | 반복해서 POST하는 스크립트 | 기본으로 켜짐: Standard, 방문자당 분당 5회 |
| 허용 도메인 | 다른 사이트에서 POST하는 폼 복사본 | 폼 설정에 도메인 추가 |
| 사람 확인 | 실제 브라우저를 조종하는 봇 | 키 없이 Cloudflare Turnstile을 쓰려면 Spam check를 Ikiform으로 설정 |
스팸은 실제 메시지와 같은 성공 응답을 받으므로 봇은 아무것도 알 수 없고, 이메일이나 웹훅도 보내지 않습니다. 문의 폼은 한 사람이 몇 분 안에 두 번 이상 POST할 일이 거의 없으므로, 공개 후에는 방문자당 10분에 2회인 Strict가 알맞습니다. 각 방어 계층은 스팸 문서에서 다루며, JavaScript 버전에서 사람 확인을 쓰려면 SDK의 challenge() 호출 하나가 더 필요합니다.
허용 도메인과 프리뷰 배포
Allowed domains는 도메인과 그 하위 도메인에 일치합니다. 정적 호스트는 생각보다 많은 도메인을 줍니다. 프로덕션 도메인, 호스트 자체의 하위 도메인, 브랜치나 풀 리퀘스트마다의 프리뷰 주소가 있습니다. 어떤 프리뷰는 사이트 호스트 주소의 하위 도메인이라 일치하고, 어떤 것은 그렇지 않아 따로 추가해야 합니다.
| 호스트 | 추가할 것 | 프리뷰 |
|---|---|---|
| GitHub Pages | username.github.io와 커스텀 도메인 | 기본적으로 프리뷰 없음 |
| Vercel | your-project.vercel.app과 여러분의 도메인 | 브랜치 URL은 일치하지 않으므로 테스트하는 동안 정확한 호스트를 추가하세요 |
| Netlify | your-site.netlify.app과 여러분의 도메인 | deploy-preview-12--your-site.netlify.app은 일치하지 않습니다 |
| Cloudflare Pages | your-project.pages.dev와 여러분의 도메인 | abc123.your-project.pages.dev는 이미 일치합니다 |
github.io, vercel.app, netlify.app 같은 공유 호스트만 단독으로 추가하지 마세요. 다른 사람의 사이트를 포함해 그 호스트의 모든 사이트가 일치하게 됩니다.
사이트가 Content-Security-Policy를 보낸다면
많은 정적 템플릿이 엄격한 Content-Security-Policy를 기본으로 포함합니다. 폼 작동 여부를 결정하는 지시어는 두 가지입니다. 일반 HTML POST에는 form-action, fetch 버전에는 connect-src입니다. 둘 중 하나라도 빠지면 브라우저가 요청이 페이지를 떠나기 전에 막고, 콘솔 메시지 하나만 남깁니다.
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.comform-action에 'self'를 남겨 두세요. 일부 브라우저는 감사 페이지로 돌아가는 리디렉션도 이 지시어로 검사합니다. 헤더를 두는 곳은 호스트마다 다릅니다. Netlify와 Cloudflare Pages는 _headers, Vercel은 vercel.json, GitHub Pages는 <meta> 태그, S3는 앞단의 CloudFront 응답 헤더 정책입니다. 아래 호스트별 페이지에서 각각을 보여 줍니다.
배포 전에 테스트하기
파일을 더블 클릭하지 말고 로컬 서버로 페이지를 여세요. file://로 연 페이지는 Origin: null을 보내므로 어떤 허용 도메인과도 일치하지 않고, 로컬 테스트로도 인정되지 않습니다. 다음 중 무엇이든 현재 폴더를 제공합니다.
npx serve .
python3 -m http.server 8000테스트하는 동안 Allowed domains에 localhost를 추가하세요. localhost에서 보낸 POST는 테스트로 취급됩니다. 플랜이 없는 워크스페이스는 한 달에 50건을 받을 수 있으므로, 결제하기 전에 폼 전체를 만들 수 있습니다.
상황별 가이드
위의 폼은 모든 정적 호스트에서 작동합니다. 호스트마다 실패하는 방식, 프리뷰 도메인, 헤더 파일이 다릅니다. 다음 페이지에서 각각을 다룹니다.
- GitHub PagesGitHub Pages는 서버 코드를 실행할 수 없어 폼 POST가 405 Not Allowed를 받습니다. 대신 폼 백엔드로 POST하고 프로젝트 사이트의 리디렉션을 올바르게 설정하세요.
- VercelVercel의 정적 페이지는 폼 POST에 405 Method Not Allowed로 응답합니다. 그 이유와 폼을 백엔드로 보내는 방법을 알아보세요.
- NetlifyNetlify Forms 없이 Netlify 사이트에서 문의 폼을 운영하세요. 빌드 시점 감지 없이 React에서 JSON을 POST하고, 배포 프리뷰용 폼을 따로 둡니다.
- Cloudflare PagesFunction을 작성하지 않고 Cloudflare Pages에 문의 폼을 추가하세요. 폼 백엔드로 POST하고, _headers에서 헤더를 설정하고, 직접 가진 Turnstile 키를 씁니다.
- Amazon S3S3 웹사이트 엔드포인트는 일반 HTTP로 GET과 HEAD만 받으므로, 문의 폼은 폼 백엔드로 POST하고 CloudFront로 리디렉션을 올바르게 설정하세요.
- 포트폴리오 웹사이트첫 통화에서 물어볼 프로젝트, 예산, 일정, 링크를 미리 묻는 포트폴리오 문의 폼입니다. 서버가 필요 없고 받은편지함에서 답장합니다.
작동 확인하기
실제 도메인에서 한 번, 호스트가 프리뷰를 만든다면 프리뷰에서 한 번 이 과정을 거치세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 localhost에서 보낸 테스트 제출을 한 달에 50건 받습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번만 결제하세요. 제출 10,000건에 $49, 50,000건에 $149이며 크레딧은 만료되지 않습니다. 가격을 참고하세요.
- 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발하는 동안에는
localhost도 추가하세요. 비워 두면 어디서 오는 POST든 받습니다. - 로컬 서버에서 제출하고 메시지가 Submissions 아래에 나타나는지 확인하세요.
- 배포한 뒤 JavaScript를 끄고 실제 사이트에서 제출하세요. 여러분의 감사 페이지에 도착해야 합니다.
- JavaScript를 다시 켜고 메시지를 비운 채 제출하세요. 오류가 호스팅 오류 페이지가 아니라 페이지 안에 나타나야 합니다.
- 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에
email필드가 있으면 Reply가 제출한 사람에게 갑니다. - 허니팟을 채워
_gotcha=bot으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. - 아무것도 오지 않았나요? 대시보드의 상태 필터를 Spam으로 바꿔 보세요. 스팸은 실제 제출과 같은 성공 응답을 받습니다.
문제 해결
| 문제 | 해결 방법 |
|---|---|
전송을 누르자마자 405 Method Not Allowed 또는 404 | 폼이 여러분의 사이트로 POST했습니다. 소스가 아니라 배포된 HTML의 action 속성을 확인하세요. 빌드 단계나 프레임워크의 <Form> 컴포넌트가 바꿔 놓았을 수 있습니다. |
아무 일도 일어나지 않고, 콘솔에 Content-Security-Policy가 언급됨 | 일반 POST를 위해 form-action에, fetch를 위해 connect-src에 https://ikiform.com을 추가하세요. |
403 origin_not_allowed, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 localhost도 추가하세요. 등록한 도메인의 하위 도메인은 허용됩니다. |
로컬 테스트가 402 plan_required로 거부됨 | 페이지를 file:// URL로 열어서 POST에 쓸 수 있는 출처가 없었고, 실제 제출로 집계되었습니다. 로컬 서버로 폴더를 제공하고 localhost에서 테스트하세요. |
| 브라우저가 여러분의 페이지 대신 Ikiform의 감사 페이지로 이동함 | _redirect는 도메인이 Allowed domains에 있고 https: URL일 때만 따릅니다. 도메인을 추가하거나, 대신 폼 설정에서 Redirect after submit을 설정하세요. |
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields가 각 필드와 이유를 알려 줍니다. 입력의 name을 필드 ID와 맞추세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않음 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 전송됩니다. |
429 rate_limited | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. Retry-After만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed 또는 Custom을 고르세요. |
질문
네. 페이지는 정적 그대로이고, 폼의 action만 POST를 받는 서비스를 가리키면 됩니다. 폼 백엔드가 메시지를 저장하고, 스팸을 거르고, 이메일로 알려 주므로 서버를 운영할 필요가 없습니다.
네. 폼 ID는 문의 페이지의 이메일 주소처럼 공개를 전제로 한 값입니다. 허용 도메인, 허니팟, 레이트 리밋, 사람 확인으로 폼을 보호하세요. 정적 사이트에 절대 있으면 안 되는 것은 시크릿 API 키입니다.
방문자에게 메일 앱이 설정되어 있어야만 작동하고, 아무것도 저장하지 않으며, 여러분의 주소를 페이지에 노출해 수집 봇의 표적이 됩니다. 폼은 방문자를 사이트에 머물게 하고 모든 메시지의 기록을 남깁니다.
아니요. 정적 호스트는 페이지를 위해 PHP나 다른 서버 코드를 실행하지 않습니다. PHP 메일 스크립트에는 서버가 필요하고, 정적 사이트는 바로 그 서버를 피하는 방식입니다.
아직은 안 됩니다. 파일이 포함된 POST는 422 files_not_supported로 거부됩니다. 대신 공유 드라이브 URL 같은 파일 링크를 요청하세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제하기 전에 localhost에서 한 달에 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.