오늘은 Getting Started 의 마지막까지 다룹니다. 생각보다 Getting Started 쓸 내용들이 많네요.
Sever / Client Components 에서 데이터를 fetch 하는 방법을 알아본다. 그리고 데이터에 따라 컨텐츠를 stream 하는 법을 알아보자.
방법은 2개다.
fetch API 는 async 함수에서 사용해야 한다.
export default async function Page() { // async 주목
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 혹은 데이터베이스에서 쿼리를 실행해서 가져올 수 있다.
import { db, posts } from '@/lib/db'
export default async function Page() {
const allPosts = await db.select().from(posts)
return (
<ul>
{allPosts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
방법은 2개다.
use 훅을 이용해 서버에서 데이터를 가져오는 과정을 stream 해야 한다. Promise 를 서버 컴포넌트에서 prop 으로 전달한다.
// 'use server'
import Posts from '@/app/ui/posts
import { Suspense } from 'react'
export default function Page() {
// Don't await the data fetching function
const posts = getPosts()
return (
<Suspense fallback={<div>Loading...</div>}>
<Posts posts={posts} />
</Suspense>
)
}
그리고 Client Component 에서 use 훅에 promise 를 이용한다.
'use client'
import { use } from 'react'
type Prop = { posts: Promise<{ id: string; title: string }[]> }
export default function Posts({ posts }: Props) {
const allPosts = use(posts)
return (
<ul>
{allPosts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
여기서 중요한 점은 Posts 컴포넌트가 Suspense 에 싸여져 있어야 한다는 것이다. Suspense 에 의해 fallback 이 렌더링 되었다가 Posts 가 렌더링 된다.
SWR, React-Query(TanStack Query) 을 사용하는 방법은 아래와 같다.
'use client'
import useSWR from 'swr'
const fetcher = (url) => fetch(url).then((r) => r.json())
export default function BlogPage() {
const { data, error, isLoading } = useSWR(
'https://api.vercel.app/blog',
fetcher
)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{data.map((post: { id: string; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
이런 라이브러리들은 자신만의 caching, streaming 등의 유용한 기능이 있다.
dynamicIO 설정 옵션이 적용되어 있어야 한다(Next.js 15 이상).
Next.js 의 서버 컴포넌트 내 async/await 는 프레임워크 레벨에서 dynamic rendering 에 최적화 되어 있다. 최적화 방법은 서버가 데이터를 fetch 하고 서버에서 컴포넌트를 렌더링하는 것이다. 만약 다수 fetch 중 하나만 느리더라도 전체 route 가 영향을 받을 수 있다.
초기 load 시간과 UX 를 최적화하기 위해서는 streaming 을 이용해서 HTML 을 작은 조각으로 나눌 수 있다.
streaming 을 구현하는 방법은 2개다.
loading.tsx 파일은 page.tsx 의 전체 페이지 stream 을 담당한다. app/blog/page.tsx 를 stream 하기 위해서는 app/blog/loading.tsx 를 이용하는 것이다.
export default function Loading() {
// Define the Loading UI here
return <div>Loading...</div>
}
처음 라우트가 되면 layout 에 loading 컴포넌트가 보이고 page 는 렌더링 시작한다. page 렌더링이 끝나면 자동으로 스왑된다.
Layout 의 시점에서 볼 때 loading.tsx 는 layout.tsx 내에 page.tsx 를 자동으로 감싼다.
그러므로 자세한 Suspense 는 직접 page.tsx 내에서 사용해야 한다.
import { Suspense } from 'react'
import BlogList from '@/components/BlogList'
import BlogListSkeleton from '@/components/BlogListSkeleton'
export default function BlogPage() {
return (
<div>
{/* This content will be sent to the client immediately */}
<header>
<h1>Welcome to the Blog</h1>
<p>Read the latest posts below.</p>
</header>
<main>
{/* Any content wrapped in a <Suspense> boundary will be streamed */}
<Suspense fallback={<BlogListSkeleton />}>
<BlogList />
</Suspense>
</main>
</div>
)
}
React Devtools 를 이용하면 로딩 상태값을 테스트해볼 수 있다.
Server Functions 를 이용하면 데이터를 업데이트할 수 있다.
'use server' 디렉티브를 사용하면 Server Function 을 만들 수 있다. asynchronous 함수 맨 위에 'use server' 를 넣어서 Server Function 을 만들거나 파일 맨 위에 넣으면 된다.
export async function createPost(formData: FormData) {
'use server'
const title = formData.get('title')
const content = formData.get('content')
// Update data
// Revalidate cache
}
export async function deletePost(formData: FormData) {
'use server'
const id = formData.get('id')
// Update data
// Revalidate cache
}
export default function Page() {
// Server Action
async function createPost(formData: FormData) {
'use server'
// ...
}
return <></>
}
하지만 Client Component 에서는 Server Function 을 정의할 수 없다. 그러나, Server Function 은 import 할 수 있다.
// app/actions.ts
'use server'
export async function createPost() {}
// app/ui/button.tsx
'use client'
import { createPost } from '@/app/actions'
export function Button() {
return <button formAction={createPost}>Create</button>
}
Server Function 을 선언하는 방법은 2가지다.
HTML form 태그를 확장한 Next.js 의 Form 태그에서 action 프롭을 이용할 수 있다. form 의 action 프롭은 Server Function 이며 FormData 객체를 받아 서버 기능을 사용할 수 있다.
// app/ui/form.tsx
import { createPost } from '@/app/actions'
export function Form() {
return (
<form action={createPost}>
<input type="text" name="title" />
<input type="text" name="content" />
<button type="submit">Create</button>
</form>
)
}
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title')
const content = formData.get('content')
// Update data
// Revalidate cache
}
Event handlers 는 아래와 같이 onClick 을 사용할 수 있다.
'use client'
import { incrementLike } from './actions'
import { useState } from 'react'
type Props = { initialLikes: number };
export function LikeButton({ initialLikes }: Props) {
const [likes, setLikes] = useState(initialLikes)
return (
<>
<p>Total Likes: {likes}</p>
<button
onClick={async () => { // 여기가 Server Function
const updatedLikes = await incrementLike()
setLikes(updatedLikes)
}}
>
Like
</button>
</>
)
}
Server Function 을 실행할 때 pending 상태를 보여주기 위해 loading indicator 를 사용하는 예시다. React 의 useActionState 를 사용한다.
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions' // Server Function
import { LoadingSpinner } from '@/app/ui/loading-spinner'
export function Button() {
const [state, action, pending] = useActionState(createPost, false)
return (
<button onClick={async () => action()}>
{pending ? <LoadingSpinner /> : 'Create Post'}
</button>
)
}
Update 수행 후 Next.js 캐시를 초기화하고 업데이트 된 데이터를 보여주기 위해 revalidatePath 혹은 revalidateTag 를 Server Function 내에서 사용한다.
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
'use server'
// Update data
// ...
revalidatePath('/posts')
}
Server Function 에서 update 뒤에 redirect 로 다른 페이지로 간다.
'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
// Update data
// ...
redirect('/posts')
}
에러는 예상 하였는지 여부에 따라 나뉘어진다.
가장 대표적인 예시는 server-side form validation 이나 failed requests 이다. 이런 에러는 핸들링 한 뒤 클라이언트에 리턴하면 된다.
Server Function 에서는 useActionState 훅이 효과적이다. 이 경우에는 try/catch 블록 혹은 throw error 를 사용하지 말고 에러를 값으로 리턴해야 한다.
'use server'
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title')
const content = formData.get('content')
const res = await fetch('https://api.vercel.app/posts', {
method: 'POST',
body: { title, content },
})
const json = await res.json()
if (!res.ok) { // throw 대신 에러를 반환
return { message: 'Failed to create post' }
}
}
서버 컴포넌트 안에서 데이터 fetch 할 땐 response 에 따라 다른 컴포넌트를 렌더링 한다.
export default async function Page() {
const res = await fetch(`https://...`)
const data = await res.json()
if (!res.ok) { // response 를 이용
return 'There was an error.'
}
return '...'
}
notFound 함수를 이용하면 404 에러를 핸들링할 수 있다.
// app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/posts'
export default async function Page({ params }: { params: { slug: string } }) {
const { slug } = await params
const post = getPostBySlug(slug)
if (!post) {
notFound()
}
return <div>{post.title}</div>
}
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return <div>404 - Page Not Found</div>
}
예상하지 못한 이슈는 throw error 로 인한 것이기 때문에 error.tsx 를 사용해야 한다.
'use client' // Error boundaries must be Client Components
import { useEffect } from 'react'
type Props = {
error: Error & { digest?: string }
reset: () => void
}
export default function Error({ error, reset }: Props) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
)
}
Error boundary 는 Layout 바로 아래 컴포넌트로 렌더링 한다.
Root Layout 은 루트 앱 디렉토리의 global-error.tsx 파일을 사용한다. global-error.tsx 에는 html, body 태그가 포함되어 있어야 한다.
'use client' // Error boundaries must be Client Components
type Props = {
error: Error & { digest?: string }
reset: () => void
}
export default function GlobalError({ error, reset }: Props) {
return (
// global-error must include html and body tags
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
)
}
Metadata API 는 SEO 향상과 웹 공유성과 다음을 제공한다.
위에 옵션에 대해 Next.js 는 자동으로 head 태그에 이를 반영한다.
Next.js 에서는 라우팅에 관계없이 아래 2개의 메타 태그가 기본으로 들어간다.
<meta charset="utf-8" /> // character encoding
<meta name="viewport" content="width=device-width, initial-scale=1" /> // viewport width, scale
나머지 요소들은 Metadata 객체 (static metadata) 혹은 generatedMetadata 함수 (generated metadata) 를 사용한다.
Metadata 객체를 layout.tsx 혹은 page.tsx 에서 사용한다.
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'My Blog',
description: '...',
}
export default function Page() {}
generate metadata documentation 참고
generatedMetadata 함수로 data 에 의존성이 있는 메타데이터를 fetch 할 수 있다. 아래 예시는 특정 블로그 포스트에 대해 제목과 설명을 fetch 한다.
import type { Metadata, ResolvingMetadata } from 'next'
type Props = {
params: Promise<{ id: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const slug = (await params).slug
// fetch post information
const post = await fetch(`https://api.vercel.app/blog/${slug}`).then((res) =>
res.json()
)
return {
title: post.title,
description: post.description,
}
}
export default function Page({ params, searchParams }: Props) {}
내부적으로 Next.js 는 메타 데이터를 fetch 하여 UI 와는 비동기적으로 메타 데이터를 html 에 넣는다.
만약 Metadata 가 같은 데이터를 갖고 있는 경우를 대비한다면 React 의 catch 함수를 사용하여 리턴 데이터를 memoize 한다.
// app/lib/data.ts
import { cache } from 'react'
import { db } from '@/app/lib/db'
// getPost will be used twice, but execute only once
export const getPost = cache(async (slug: string) => {
const res = await db.query.posts.findFirst({ where: eq(posts.slug, slug) })
return res
})
// app/blog/[slug]/page.tsx
import { getPost } from '@/app/lib/data'
export async function generateMetadata({
params,
}: {
params: { slug: string }
}) {
// fetch 를 대체
const post = await getPost(params.slug)
return {
title: post.title,
description: post.description,
}
}
export default async function Page({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return <div>{post.title}</div>
}
app 디렉토리에 favicon.ico 를 넣는다.
OG image 는 SNS 에 노출되는 이미지이다. app 디렉토리 혹은 특정 라우트 디렉터리에 opengraph-image.jpg 파일을 넣는다.
dynamic OG image 를 사용하고 싶을 경우 ImageResponse 생성자를 이용한다.
import { ImageResponse } from 'next/og'
import { getPost } from '@/app/lib/data'
// Image metadata
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
// Image generation
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return new ImageResponse(
(
// ImageResponse JSX element
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{post.title}
</div>
)
)
}
Next.js 앱이 준비되었다? managed infrastructure provider 혹은 self-host 로 웹 애플리케이션을 배포한다.
Vercel 에서 했으면 좋겠다고 한다.
실제 서버를 구축하는 것을 말한다.
npx @next/codemod@canary upgrade latest
이렇게도 활용할 수 있다.
npm i next@latest react@latest react-dom@latest eslint-config-next@latest
2025-04-23 기준 현재 최신 버전은 Next.js 15 canary 이다.
npm i next@canary