title: Form Component (Form 컴포넌트)
description: "<Form> 컴포넌트를 사용하여 폼 제출과 검색 파라미터(search params) 업데이트를 클라이언트 사이드 네비게이션과 함께 처리하는 방법을 배워봅니다."
url: "https://nextjs.org/docs/app/api-reference/components/form"
version: 16.1.6
lastUpdated: 2026-02-27
prerequisites:
안녕하세요 여러분! 오늘은 Next.js에서 폼(Form)을 다룰 때 아주 유용하게 쓸 수 있는 <Form> 컴포넌트에 대해 알아보겠습니다. 공식 문서를 바탕으로 하나하나 짚어볼 텐데요, 이해하기 쉽게 설명해 드릴 테니 잘 따라와 주세요!
<Form> 컴포넌트는 우리가 흔히 아는 기존 HTML의 <form> 태그를 확장한 특별한 녀석입니다. 단순히 데이터를 전송하는 걸 넘어서, 로딩 UI의 프리패칭(Prefetching), 폼 제출 시의 클라이언트 사이드 네비게이션, 그리고 점진적 향상(Progressive Enhancement) 이라는 강력한 기능들을 제공하죠.
💡 강사의 팁: 점진적 향상(Progressive Enhancement)이 뭔가요?
자바스크립트가 브라우저에서 아직 로드되기 전이거나, 에러로 인해 실행되지 않는 상황에서도 폼 제출이라는 기본적인 기능이 작동하도록 보장해 주는 웹 개발의 중요한 원칙입니다. Next.js의<Form>은 이 부분을 기본적으로 신경 써주기 때문에 훨씬 견고한 웹앱을 만들 수 있어요.
특히 URL의 검색 파라미터(search params, 예: ?query=abc)를 업데이트해야 하는 폼을 만들 때 정말 유용합니다. 위에서 말한 기능들을 직접 구현하려면 작성해야 할 보일러플레이트(반복적이고 기초적인) 코드가 꽤 많은데, 그걸 확 줄여주거든요.
기본적인 사용법은 다음과 같습니다:
import Form from 'next/form'
export default function Page() {
return (
<Form action="/search">
{/* On submission, the input value will be appended to
the URL, e.g. /search?query=abc */}
<input name="query" />
<button type="submit">Submit</button>
</Form>
)
}
import Form from 'next/form'
export default function Search() {
return (
<Form action="/search">
{/* On submission, the input value will be appended to
the URL, e.g. /search?query=abc */}
<input name="query" />
<button type="submit">Submit</button>
</Form>
)
}
<Form> 컴포넌트가 어떻게 동작할지는 action 프롭(prop)에 문자열(string) 을 넣느냐, 아니면 함수(function) 를 넣느냐에 따라 달라집니다. 이 차이를 확실히 알아두셔야 해요!
action에 문자열(string) 을 전달했을 때:
이 경우 <Form>은 GET 메서드를 사용하는 네이티브 HTML 폼처럼 동작합니다. 폼 데이터는 URL의 검색 파라미터(search params)로 인코딩되어 들어가고, 폼이 제출되면 지정된 URL로 이동하게 되죠. 여기에 더해 Next.js가 아주 똑똑한 일들을 해줍니다:
layout.js나 loading.js 같은 것들)를 미리 로드해 두어서 페이지 이동이 훨씬 빨라집니다.action에 함수(function) (즉, 서버 액션 - Server Action)를 전달했을 때:
이때는 <Form>이 기존의 React 폼(React form)처럼 동작하며, 폼이 제출될 때 해당 액션 함수를 실행하게 됩니다.
💡 강사의 팁: 언제 뭘 써야 할까요?
검색 페이지, 필터링, 정렬 같이 "데이터를 조회"해서 URL에 남겨야 하는 경우에는 문자열(string) 을 쓰세요. 반면에 회원가입, 댓글 작성, 상품 등록 같이 서버의 데이터를 변경(Mutation)해야 할 때는 함수(Server Action) 를 사용하시는 게 정석입니다!
action 프롭에 문자열을 넣을 때 (String Props)action이 문자열일 경우, <Form> 컴포넌트는 다음과 같은 프롭들을 지원합니다:
| 프롭 (Prop) | 예시 (Example) | 타입 (Type) | 필수 여부 (Required) |
|---|---|---|---|
action | action="/search" | string (URL 또는 상대 경로) | 네 (Yes) |
replace | replace={false} | boolean | - |
scroll | scroll={true} | boolean | - |
prefetch | prefetch={true} | boolean | - |
action: 폼이 제출되었을 때 이동할 URL이나 경로입니다.""을 넣게 되면, 현재 경로에 머무르면서 검색 파라미터(search params)만 업데이트됩니다. (현재 페이지에서 필터만 바꿀 때 아주 유용하죠!)replace: 페이지를 이동할 때 브라우저의 방문 기록(history) 스택에 새로운 기록을 밀어 넣는(push) 대신, 현재 상태를 아예 교체(replace)할지 결정합니다. 기본값은 false입니다.replace={true}를 고려해보세요. 안 그러면 뒤로가기 버튼을 수십 번 눌러야 빠져나올 수 있는 불상사가 생길 수 있습니다!scroll: 페이지 이동 중의 스크롤 동작을 제어합니다. 기본값은 true이며, 이 경우 새로운 경로로 이동 시 맨 위로 스크롤되고, 앞으로 가기/뒤로 가기를 할 때는 기존 스크롤 위치를 유지해줍니다.prefetch: 사용자 화면에 폼이 노출되었을 때 해당 경로를 미리 패치(prefetch)할지 말지를 결정합니다. 기본값은 true입니다.action 프롭에 함수를 넣을 때 (Function Props)action이 함수일 경우, <Form> 컴포넌트는 다음 프롭만 지원합니다:
| 프롭 (Prop) | 예시 (Example) | 타입 (Type) | 필수 여부 (Required) |
|---|---|---|---|
action | action={myAction} | function (Server Action) | 네 (Yes) |
action: 폼이 제출될 때 호출될 서버 액션(Server Action)입니다. 더 자세한 내용은 React 공식 문서를 참고해 보세요.알아두면 좋은 점 (Good to know):
action이 함수일 때는replace와scroll프롭을 넣어봤자 무시됩니다. 서버 액션 내에서 어떻게 처리하느냐에 달려있기 때문이죠.
이 부분은 실무에서 버그를 마주치기 쉬운 부분이니 집중해서 봐주세요!
formAction: <button>이나 <input type="submit"> 태그에서 <Form>의 action 프롭을 덮어쓰기 위해 사용할 수 있습니다. 이 경우 Next.js가 클라이언트 사이드 네비게이션은 알아서 처리해 주지만, 아쉽게도 프리패칭(prefetching)은 지원하지 않습니다.basePath를 사용 중이라면, formAction 경로에도 그걸 꼭 포함시켜야 합니다. 예: formAction="/base-path/search".key: 문자열 action을 사용할 때는 key 프롭을 전달하는 것을 지원하지 않습니다. 만약 컴포넌트를 강제로 리렌더링하거나 데이터를 변경(mutation)해야 하는 상황이라면, 문자열 대신 함수 action 사용을 고려해보세요.onSubmit: 폼 제출 로직을 직접 다룰 때 사용할 수 있습니다. 하지만 여기서 매우 중요한 점! event.preventDefault()를 호출해버리면, 지정된 URL로 이동하는 등의 멋진 <Form> 기본 동작들이 모두 덮어써져서(취소되어서) 무용지물이 됩니다. e.preventDefault()를 적는 게 국룰이었죠? Next.js의 <Form>을 쓸 때는 그걸 습관적으로 쓰시면 안 됩니다! Next.js가 알아서 새로고침 없이 부드럽게 넘겨주니까 믿고 맡기세요.method, encType, target: 이 HTML 기본 속성들은 <Form>의 동작을 방해하므로 지원하지 않습니다.formMethod, formEncType, formTarget을 사용해 각 프롭을 덮어쓸 수는 있지만, 이렇게 하면 Next.js의 기능이 꺼지고 브라우저 기본(네이티브) 동작으로 돌아가 버립니다.<Form>을 쓰지 마시고 그냥 일반 HTML <form> 태그를 사용하셔야 합니다.<input type="file">: action이 문자열일 때 이 파일 입력 타입을 사용하면 브라우저 기본 동작과 동일하게 동작합니다. 즉, 파일 객체 자체가 전송되는 게 아니라 파일 이름(filename)만 제출되니 주의하세요. 파일을 업로드해야 한다면 서버 액션(함수)을 사용하거나 다른 API 엔드포인트를 활용해야 합니다.백문이 불여일견이죠. 실제로 어떻게 코드를 작성하는지 예제를 통해 확인해 봅시다.
action 프롭에 이동할 경로를 전달해서 검색 결과를 보여주는 페이지로 넘어가는 폼을 만들 수 있습니다.
import Form from 'next/form'
export default function Page() {
return (
<Form action="/search">
<input name="query" />
<button type="submit">Submit</button>
</Form>
)
}
import Form from 'next/form'
export default function Page() {
return (
<Form action="/search">
<input name="query" />
<button type="submit">Submit</button>
</Form>
)
}
사용자가 검색어 입력칸(input)을 수정하고 폼을 제출하면, 폼 데이터가 URL의 검색 파라미터로 예쁘게 인코딩되어 들어갑니다. 결과적으로 /search?query=abc 같은 형태의 URL이 되겠죠.
알아두면 좋은 점 (Good to know): 앞서 설명했듯이,
action에 빈 문자열""을 전달하면 경로 이동 없이 현재 페이지 내에서 검색 파라미터만 싹 업데이트됩니다.
이제 검색 결과를 보여줄 결과 페이지 쪽 코드를 볼까요? searchParams 프롭을 통해서 쿼리값을 가져오고, 그걸 외부 소스(DB나 외부 API)로 데이터를 패칭하는 데 사용할 수 있습니다.
import { getSearchResults } from '@/lib/search'
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const results = await getSearchResults((await searchParams).query)
return <div>...</div>
}
import { getSearchResults } from '@/lib/search'
export default async function SearchPage({ searchParams }) {
const results = await getSearchResults((await searchParams).query)
return <div>...</div>
}
<Form> 컴포넌트가 화면에 보일 때, /search 페이지에 있는 공통 UI(layout.js와 loading.js)가 백그라운드에서 조용히 프리패치(미리 불러오기) 됩니다. 그리고 사용자가 제출 버튼을 누르면, 브라우저는 즉시 새로운 경로로 이동하고 검색 결과를 가져오는 동안 로딩 UI를 보여주게 됩니다.
로딩 중일 때 보여줄 화면은 loading.js 파일에서 예쁘게 꾸며줄 수 있어요:
export default function Loading() {
return <div>Loading...</div>
}
export default function Loading() {
return <div>Loading...</div>
}
가끔 공통 UI마저도 아직 로드되지 않은 찰나의 순간이 있을 수 있겠죠? 이럴 때 사용자에게 즉각적인 피드백(예: 버튼 텍스트가 "검색 중..."으로 변경됨)을 주기 위해 React의 useFormStatus 훅을 함께 사용할 수 있습니다.
먼저, 폼 제출이 진행 중(pending)일 때 로딩 상태를 보여주는 컴포넌트를 분리해서 만들어 줍니다:
💡 강사의 팁:
useFormStatus훅을 쓸 때는 반드시<form>의 직계 자식이나 그 하위 컴포넌트 내부에서 호출되어야 합니다. 그래서 아래처럼 버튼을 별도 컴포넌트로 분리하는 거예요!
'use client'
import { useFormStatus } from 'react-dom'
export default function SearchButton() {
const status = useFormStatus()
return (
<button type="submit">{status.pending ? 'Searching...' : 'Search'}</button>
)
}
'use client'
import { useFormStatus } from 'react-dom'
export default function SearchButton() {
const status = useFormStatus()
return (
<button type="submit">{status.pending ? 'Searching...' : 'Search'}</button>
)
}
그 다음, 방금 만든 SearchButton 컴포넌트를 메인 폼 페이지에 쏙 넣어주면 됩니다:
import Form from 'next/form'
import { SearchButton } from '@/ui/search-button'
export default function Page() {
return (
<Form action="/search">
<input name="query" />
<SearchButton />
</Form>
)
}
import Form from 'next/form'
import { SearchButton } from '@/ui/search-button'
export default function Page() {
return (
<Form action="/search">
<input name="query" />
<SearchButton />
</Form>
)
}
이번에는 데이터를 변경하거나 추가할 때 사용하는 방법입니다. action 프롭에 함수를 직접 전달하면 되는데요.
import Form from 'next/form'
import { createPost } from '@/posts/actions'
export default function Page() {
return (
<Form action={createPost}>
<input name="title" />
{/* ... */}
<button type="submit">Create Post</button>
</Form>
)
}
import Form from 'next/form'
import { createPost } from '@/posts/actions'
export default function Page() {
return (
<Form action={createPost}>
<input name="title" />
{/* ... */}
<button type="submit">Create Post</button>
</Form>
)
}
데이터를 변경(예: 새로운 글 작성)하고 나면, 방금 만든 그 글로 사용자를 이동(리다이렉트)시켜주는 것이 아주 일반적인 흐름입니다. 이때는 next/navigation 모듈에서 제공하는 redirect 함수를 사용해서 새 게시글 페이지로 넘겨주면 됩니다.
알아두면 좋은 점 (Good to know): 서버 액션을 사용할 때는 해당 액션 함수가 끝까지 실행되기 전까지는 사용자가 최종적으로 "어디로" 이동하게 될지 알 수가 없습니다. 따라서 이 경우에는
<Form>컴포넌트가 공통 UI를 자동으로 미리 불러올(prefetch) 수 없다는 점을 기억해 두세요.
'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
// 새로운 포스트를 생성하는 로직을 여기에 작성합니다.
// ...
// 생성이 완료되면 방금 만든 포스트 페이지로 리다이렉트 시킵니다!
redirect(`/posts/${data.id}`)
}
'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData) {
// 새로운 포스트를 생성하는 로직을 여기에 작성합니다.
// ...
// 생성이 완료되면 방금 만든 포스트 페이지로 리다이렉트 시킵니다!
redirect(`/posts/${data.id}`)
}
그리고 새롭게 이동한 페이지에서는 URL의 동적 경로(예: [id])를 params 프롭으로 받아와서 데이터를 화면에 뿌려주면 끝입니다:
import { getPost } from '@/posts/data'
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const data = await getPost(id)
return (
<div>
<h1>{data.title}</h1>
{/* ... */}
</div>
)
}
import { getPost } from '@/posts/data'
export default async function PostPage({ params }) {
const { id } = await params
const data = await getPost(id)
return (
<div>
<h1>{data.title}</h1>
{/* ... */}
</div>
)
}
데이터 변경과 서버 액션에 대해 더 많은 예시가 필요하다면 Server Actions (서버 액션) 문서를 꼭 읽어보시길 권해드립니다!
모든 문서의 의미론적 개요를 보시려면 https://nextjs.org/docs/sitemap.md 를 확인해 보세요.
사용 가능한 전체 문서의 목차(index)는 https://nextjs.org/docs/llms.txt 에서 확인하실 수 있습니다.
수고하셨습니다! <Form> 컴포넌트를 활용하면 UX(사용자 경험)는 향상시키고 코드는 훨씬 깔끔해진다는 걸 느끼셨을 거예요. 배운 내용을 토대로 여러분의 프로젝트에 바로 적용해 보시기 바랍니다!