# Next.js

> Next.js App Router 클라이언트 컴포넌트에서 제출하거나, 시크릿 키를 사용하는 서버 액션으로 제출하세요.

Next.js에서 제출하는 좋은 방법은 두 가지입니다. 클라이언트 컴포넌트에서 브라우저가 POST하면 방문자마다 자신의 IP로 레이트 리밋이 적용되고 모든 스팸 검사와 함께 작동합니다. 서버 액션에서 POST하면 JavaScript 없이도 폼이 작동합니다.

|   | 클라이언트 컴포넌트 | 서버 액션 |
| --- | --- | --- |
| JavaScript 없이 작동 | 아니요 | 예 |
| 레이트 리밋 집계 기준 | 방문자별 | 서버의 IP |
| Ikiform 사람 확인 | SDK의 `challenge` 사용 | 시크릿 키로 건너뜀 |
| 허용 도메인 | 브라우저의 출처와 비교 | 시크릿 키로 건너뜀 |
| 시크릿 키 필요 | 아니요 | 예 |

## SDK 설치

`Terminal`:

```bash
npm install @ikiform/sdk
```

SDK는 선택 사항입니다. 아래 내용은 모두 `fetch`로도 작동합니다. [JavaScript와 fetch](https://ikiform.com/ko/docs/frameworks.md)를 참고하세요.

## 클라이언트 컴포넌트에서 보내기

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { submit } from "@ikiform/sdk";
import { useState } from "react";

type Status = "idle" | "sending" | "sent" | "error";

export function ContactForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(event.currentTarget));
    if (result.ok) return setStatus("sent");
    setErrors(result.error.fields ?? {});
    setMessage(result.error.message);
    setStatus("error");
  }

  if (status === "sent") return <p>Thanks, we got it.</p>;

  return (
    <form onSubmit={onSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required aria-invalid={Boolean(errors.email)} />
      {errors.email ? <p>{errors.email[0]}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status === "error" && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send"}</button>
    </form>
  );
}
```

- `submit`은 예외를 던지지 않습니다. `{ ok: true, id }` 또는 `{ ok: false, error, status, requestId }`로 resolve됩니다.
- 폼에 [필드](https://ikiform.com/ko/docs/fields.md)가 정의되어 있으면 `error.fields`가 필드 ID와 메시지를 연결합니다.
- `app/contact/page.tsx`처럼 아무 서버 컴포넌트 페이지에서 컴포넌트를 렌더링하세요.

### Ikiform 사람 확인과 함께 사용

폼의 스팸 검사가 **Ikiform**이라면 제출할 때마다 직전에 토큰을 받으세요. `challenge`는 Turnstile에 클릭이 필요할 때만 보이는 작은 ikiform.com 프레임을 마운트합니다.

`contact-form.tsx`:

```tsx
const data = new FormData(event.currentTarget);
const turnstileToken = await challenge("frm_xxxxxxxxxxxxxxxx");
const result = await submit("frm_xxxxxxxxxxxxxxxx", data, { turnstileToken });
```

`import { challenge, submit } from "@ikiform/sdk";`로 가져오세요. 토큰은 한 번만 쓸 수 있으므로 제출마다 한 번씩 호출하세요.

## 서버 액션에서 보내기

대시보드의 **API keys**에서 `submissions:write` 스코프로 시크릿 키를 만들고 환경 변수로 저장하세요. 브라우저에 절대 노출하지 마세요. `NEXT_PUBLIC_` 접두사를 붙이면 안 됩니다.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`app/contact/actions.ts`:

```ts
"use server";

export interface ContactState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
}

export async function sendContact(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const body = new URLSearchParams();
  for (const [name, value] of formData) {
    if (typeof value === "string" && !name.startsWith("$ACTION")) body.append(name, value);
  }
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
    },
    body,
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks, we got it." };
  return { ok: false, message: result.error.message, fields: result.error.fields };
}
```

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type ContactState, sendContact } from "./actions";

const initial: ContactState = { ok: false, message: "" };

export function ContactForm() {
  const [state, action, pending] = useActionState(sendContact, initial);

  if (state.ok) return <p>{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      {state.fields?.email ? <p>{state.fields.email[0]}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send"}</button>
    </form>
  );
}
```

> 서버에서 보낸 POST는 모두 서버의 IP 주소에서 오므로, Ikiform의 방문자별 레이트 리밋이 모든 방문자를 합쳐서 셉니다. Standard 프리셋은 IP와 폼별로 분당 5건을 허용하며, 트래픽이 많은 사이트는 이 한도에 닿을 수 있습니다. 가능하면 브라우저에서 POST하거나, 서버를 거쳐 보내는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르세요.

SDK의 `submit`은 서버 액션에서도 쓸 수 있지만 `Authorization` 헤더를 보내지 않으므로, 허용 도메인이나 Ikiform 사람 확인을 쓰는 폼은 POST를 거부하거나 스팸으로 표시합니다. 예제처럼 시크릿 키와 함께 `fetch`를 사용하세요.

## 라우트 핸들러에서 웹훅 받기

Next.js 앱에서 제출마다 작업을 처리하려면 라우트 핸들러를 추가하세요. [웹훅 받기](https://ikiform.com/ko/docs/guides/webhook-handlers.md#nextjs)를 참고하세요.
