서버에서 읽고, 브라우저에서는 눌러야 하는 부분만 분리합니다.
| 필요 | 두는 곳 |
|---|---|
| DB·API 조회, 비밀 키, HTML만으로 충분한 목록 | 서버 컴포넌트. 지시어 없음 |
onClick, useState, useEffect, window |
클라이언트 컴포넌트. 파일 첫 줄 'use client' |
'use client'는 “이 컴포넌트는 브라우저에서만 실행된다”가 아닙니다. 서버도 첫 HTML을 만들 때 그 컴포넌트를 그립니다. 지시어는 서버 모듈 그래프와 클라이언트 모듈 그래프의 경계입니다. 그 파일이 가져온 모듈까지 브라우저 묶음에 들어갑니다. 그래서 버튼 파일에만 붙이고, 레이아웃 전체에 붙이지 않습니다.
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<p>
{count}
<button type="button" onClick={() => setCount(count + 1)}>
증가
</button>
</p>
)
}
서버 컴포넌트인 페이지에서 이 파일을 가져오면 됩니다. 페이지 자체에는 'use client'를 적지 않습니다.
서버 컴포넌트는 async일 수 있습니다. 조회 결과를 props로 넘깁니다. props는 React가 직렬화할 수 있는 값이어야 합니다. 함수, 클래스 인스턴스, Date처럼 그대로 못 넘는 값은 넘기지 않습니다.
import LikeButton from './like-button'
export default async function Page({ params }) {
const { id } = await params
const post = await getPost(id)
return (
<>
<h1>{post.title}</h1>
<LikeButton likes={post.likes} />
</>
)
}
'use client'
export default function LikeButton({ likes }) {
return <p>좋아요 {likes}</p>
}
클라이언트 파일이 서버 컴포넌트를 import하면 그 서버 컴포넌트도 브라우저 묶음으로 끌려갑니다. 서버에서 그린 결과를 넣고 싶으면 children으로 받습니다. children은 부모(서버)가 미리 그린 결과입니다.
'use client'
export default function Modal({ children }) {
return <div role="dialog">{children}</div>
}
import Modal from './modal'
import Cart from './cart'
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
)
}
Cart가 서버 컴포넌트여도, Page가 서버에서 Modal의 자식으로 넘기면 Cart는 서버에서 실행됩니다.
같은 트리 안의 같은 fetch는 React가 한 번으로 줄입니다. 컴포넌트마다 필요한 곳에서 호출해도 됩니다. 응답은 기본으로 캐시되지 않습니다.
export default async function Page() {
const data = await fetch('https://api.vercel.app/blog')
const posts = await data.json()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
ORM이나 DB 클라이언트도 서버 컴포넌트에서 바로 부릅니다. 쿼리 문자열은 브라우저 묶음에 들어가지 않습니다. 그래도 “이 사용자가 이 글을 볼 수 있는가”는 따로 검사합니다. 서버에서 돈다는 사실만으로 권한 검사가 끝나지 않습니다.
헤더를 먼저 보여주고 목록만 기다리려면 Suspense로 목록을 감쌉니다.
import { Suspense } from 'react'
import BlogList from '@/components/BlogList'
export default function Page() {
return (
<>
<h1>블로그</h1>
<Suspense fallback={<p>목록을 불러오는 중</p>}>
<BlogList />
</Suspense>
</>
)
}
BlogList가 async 서버 컴포넌트이면, 헤더는 먼저 가고 목록은 준비되는 대로 이어 붙습니다. @/는 create-next-app이 기본으로 넣는 경로 별칭입니다. 프로젝트 루트를 가리킵니다.
서버에서 실행되는 함수에 'use server'를 붙입니다. 파일 맨 위에 두면 그 파일의 export가 모두 서버 함수입니다. 폼의 action에 넘기면 제출 시 서버에서 실행됩니다.
'use server'
export async function createPost(formData) {
const title = formData.get('title')
// 여기서 DB에 저장한다. 비밀 키도 서버에만 있다.
}
import { createPost } from './actions'
export default function Page() {
return (
<form action={createPost}>
<label htmlFor="title">제목</label>
<input id="title" name="title" />
<button type="submit">저장</button>
</form>
)
}
입력과 라벨은 id와 htmlFor로 연결합니다. placeholder만으로 이름표를 대신하지 않습니다.
.env.local에 두고 Git에는 올리지 않습니다.process.env.API_KEY는 서버에서만 값이 있습니다. 클라이언트 묶음에서는 빈 문자열로 바뀝니다.NEXT_PUBLIC_을 붙입니다. 예: NEXT_PUBLIC_SITE_URL. 이 접두어가 붙은 값은 소스에 남으므로 비밀이 아닙니다.서버 전용 파일을 클라이언트가 가져가면 빌드가 실패하게 만들 수 있습니다. 파일 첫 줄에 import 'server-only'를 둡니다. 패키지는 npm install server-only로 추가합니다.