Next.js Forms 가이드 정리 (App Router)
1. Forms 개요
Forms(폼) 가이드는 Next.js(App Router)에서 React Server Actions(= Server Functions) 로 폼 제출을 처리하는 방법을 설명합니다.
<form action={serverAction}>형태로 서버에서 실행되는 함수를 바로 호출할 수 있습니다.- 폼에서 호출된 Server Action은 자동으로
FormData를 받아서 값 추출이 가능합니다. - 폼 제출 후 DB 업데이트 같은 데이터 변경(mutation) 과 함께, 필요하다면 캐시 재검증(revalidate) 도 연결할 수 있습니다.
구성은 크게 아래 흐름으로 이해하면 됩니다.
- Server Action을 만들고
<form action={...}>로 연결하기 - 추가 인자 전달이 필요하면
bind()또는 hidden input으로 처리하기 - 검증/에러/로딩/낙관적 업데이트 등 UX를 단계적으로 붙이기
2. Step 1: Server Action 연결하기
가장 기본적인 형태는 Server Component 안에서 Server Action을 정의하고 use server 지시어를 붙이는 방식입니다.
// app/invoices/page.tsx (예시)
export default function Page() {
async function createInvoice(formData: FormData) {
'use server'
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
}
// 1) mutate data (DB 업데이트 등)
// 2) revalidate cache (필요 시)
}
return <form action={createInvoice}>{/* ... */}</form>
}
참고 팁:
- 필드가 많으면
Object.fromEntries(formData)를 사용할 수 있습니다. - 다만 이 객체에는
$ACTION_로 시작하는 추가 속성이 포함될 수 있다는 점을 주의합니다.
3. Step 2: 추가 인자 전달하기 (Passing additional arguments)
폼 필드 외에 userId 같은 값을 Server Action에 같이 넘기고 싶다면, bind() 로 인자를 미리 고정할 수 있습니다.
// app/client-component.tsx
'use client'
import { updateUser } from './actions'
export function UserProfile({ userId }: { userId: string }) {
const updateUserWithId = updateUser.bind(null, userId)
return (
<form action={updateUserWithId}>
<input type="text" name="name" />
<button type="submit">Update User Name</button>
</form>
)
}
서버 액션 시그니처는 다음처럼 추가 인자 + FormData 형태로 받습니다.
// app/actions.ts
'use server'
export async function updateUser(userId: string, formData: FormData) {
// ...
}
대안:
- hidden input으로 전달도 가능하지만, 그 값은 HTML에 노출됩니다.
4. Step 3: Form validation (클라이언트/서버 검증)
폼 검증은 크게 2가지가 같이 사용됩니다.
- 클라이언트:
required,type="email"같은 HTML 기본 검증 - 서버: Zod 같은 라이브러리로 서버에서 최종 검증
// app/actions.ts
'use server'
import { z } from 'zod'
const schema = z.object({
email: z.string({ invalid_type_error: 'Invalid Email' }),
})
export default async function createUser(formData: FormData) {
const validatedFields = schema.safeParse({
email: formData.get('email'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
// Mutate data
}
5. Step 4: Validation errors 표시하기
서버 검증 결과를 화면에 보여주려면, <form>을 가진 컴포넌트를 Client Component로 전환하고 React의 useActionState 를 사용합니다.
핵심 포인트:
useActionState를 쓰면 Server Action 시그니처가(prevState, formData)형태로 바뀝니다.- Client에서
state를 보고 에러/메시지를 렌더링합니다.
// app/ui/signup.tsx (예시)
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
const initialState = { message: '' }
export function Signup() {
const [state, formAction] = useActionState(createUser, initialState)
return (
<form action={formAction}>
<input name="email" type="email" required />
{state?.message ? <p>{state.message}</p> : null}
<button type="submit">Sign up</button>
</form>
)
}
6. Step 5: Pending states (로딩/비활성 처리)
6-1) useActionState로 pending 처리
useActionState는 pending boolean을 함께 제공하므로, 제출 버튼 비활성화 같은 처리가 쉽습니다.
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
export function Signup() {
const [state, formAction, pending] = useActionState(createUser, initialState)
return (
<form action={formAction}>
{/* ... */}
<button disabled={pending}>Sign up</button>
</form>
)
}
6-2) useFormStatus로 pending 처리 (컴포넌트 분리)
useFormStatus를 쓰면, 버튼을 별도 컴포넌트로 분리해서 폼 내부에서 pending 상태만 읽을 수 있습니다.
// app/ui/button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending} type="submit">
Sign Up
</button>
)
}
참고: React 19에서는
useFormStatus()가pending외에data,method,action같은 추가 키를 포함할 수 있습니다(버전 차이 주의).
7. Step 6: Optimistic updates (낙관적 UI)
서버 응답을 기다리기 전에 UI를 먼저 업데이트하고 싶다면 React의 useOptimistic 를 사용합니다.
'use client'
import { useOptimistic } from 'react'
import { send } from './actions'
type Message = { message: string }
export function Thread({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic<Message[], string>(
messages,
(state, newMessage) => [...state, { message: newMessage }]
)
const formAction = async (formData: FormData) => {
const message = formData.get('message') as string
addOptimisticMessage(message)
await send(message)
}
return (
<div>
{optimisticMessages.map((m, i) => (
<div key={i}>{m.message}</div>
))}
<form action={formAction}>
<input type="text" name="message" />
<button type="submit">Send</button>
</form>
</div>
)
}
8. Step 7: Nested form elements (폼 내부에서 여러 액션)
<form> 내부에 있는 <button>, <input type="submit"> 같은 요소에서 formAction 을 활용하면,
하나의 폼 안에서 여러 Server Action 을 호출하는 패턴도 가능합니다.
예: “임시저장” 버튼은 draft 저장 액션, “발행” 버튼은 publish 액션 등.
9. Step 8: Programmatic form submission (프로그래밍 방식 제출)
키보드 단축키(예: ⌘ + Enter) 등으로 폼 제출을 트리거하고 싶다면, requestSubmit()을 사용할 수 있습니다.
'use client'
export function Entry() {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'Enter' || e.key === 'NumpadEnter')) {
e.preventDefault()
e.currentTarget.form?.requestSubmit()
}
}
return <textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
}
10. 정리 및 활용 팁
- Server Action +
<form action>조합이 핵심이며, 제출 데이터는FormData로 받습니다. - UX는
useActionState(에러+pending),useFormStatus(폼 상태),useOptimistic(낙관적 UI)로 단계적으로 강화합니다. - 데이터 변경 후 목록/상세가 즉시 갱신되어야 한다면, Server Action 안에서 캐시 재검증(revalidate)을 함께 설계하는 흐름이 자연스럽습니다.