동적 경로
localhost:3000/search?q=1
[src/app/search/page.tsx]
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ q: string }>;
}) {
const { q } = await searchParams;
return <div>Search 페이지 : {q}</div>;
}
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <div>book/ [id] 페이지 : {id}</div>;
}
[src/app/[with-searchbar]]]
: 하나의 소괄호()로 된 폴더를 만들어 놓고, 해당 폴더에 page.tsx를 넣으면
경로에 영향을 미치지 않지만 해당 폴더 내에 layout.tsx를 만들어 놓으면, 해당 폴더내에 있는 page.tsx에만 layout이 적용이 되어 유용하게 쓰일 수 있다.
book 폴더의 page.tsx에는 layout이 적용이 안된다.
"use client";
import { useState } from "react";
export default function Searchbar() {
const [search, setSearch] = useState("");
const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
return (
<div>
<input value={search} onChange={onChangeSearch} />
<button>검색</button>
</div>
);
}
[src/app/layout.tsx]
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<header>
<Link href={"/"}>index</Link>
<Link href={"/search"}>Search</Link>
<Link href={"/book/1"}>book/1</Link>
</header>
{children}
</body>
</html>
);
import Link from "next/link";
를 통해 네비게이팅이 가능하다.
import { useRouter } from "next/navigation";
export default function Searchbar() {
const router = useRouter();
...
const onSubmit = () => {
router.push(`/search?q=${search}`);
};
return (
<div>
<input value={search} onChange={onChangeSearch} />
<button onClick={onSubmit}>검색</button>
</div>
);
import { useRouter } from "next/navigation";
useRouter를 통해서 프로그래매틱한 페이지 이동도 가능하다.
주의) Page Router 버전에서는 from 'next/router'; 를 사용
: 데이터를 미리 가져와 빠르게 화면을 렌더링
Link 태그 사용 시 자동으로 pre-fetching 지원
=> 기존의 getServerSideProps, getStaticProps ...를 대체한다!!
fetch 메서드를 활용해 불러온 데이터를 Next 서버에서 보관하는 기능
: 영구적으로 데이터를 보관 / 특정 시간을 주기로 갱신
=> 불 필요한 데이터 요청의 수를 줄여 웹 서비스 성능을 크게 개선
=> 오직 fetch 메서드에서만 활용 가능
Next 서버측에서 빌드 타임에 특정 페이지의 렌더링 결과를 캐싱하는 기능
정적(Static) 페이지에서만 풀 라우트 캐시를 적용해서 빠른 속도로 처리 가능
=> { cache: "force-cache" }를 fetch함수에서 적용해주면 된다.
정적인 param을 빌드 타임에 만들어내는 기능
export function generateStaticParams() {
return [{ id: "1" }, { id: "2" }, { id: "3" }];
}
export default async function Page({
params,
}: {
params: Promise<{ id: string | string[] }>;
}) {
const param = await Promise.resolve(params); // `params`를 비동기적으로 해석
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/${param.id}`
);
generateStaticParams 안에 배열로 특정 id를 설정해서 빌드 타임에 호출하여
속도가 빠르게 가능하다.
특정 id가 설정된 param 외에 호출이 될 시에 실시간으로 동적 페이지로 만들어진다.
특정 페이지에 캐싱이나 Revalidate 동작을 강제로 설정할 수 있게해주는 옵션
export const dynamicParmas = false;
// 정적인 param을 빌드 타임에 만들어내는 기능
export function generateStaticParams() {
return [{ id: "1" }, { id: "2" }, { id: "3" }];
}
generateStaticParams으로 설정된 param 외에 호출 시
dynamicParmas = false 옵션으로 강제로 404 Page로 이동
export const dynamic = "force-dynamic";
// 특정 페이지의 유형을 강제로 Static, Dynamic 페이지로 설정
// 1. auto : 기본 값, 아무것도 강제하지 않음.
// 2. force-dynamic : 페이지를 강제로 Dynamic 페이지로 설정
// 3. force-static : 페이지를 강제로 Static 페이지로 설정
// 4. error : 페이지를 강제로 Static 페이지로 설정 (설정하면 안되는 이유 => 빌드 오류)
...
async function SearchResult({ q }: { q: string }) {
await delay(1500);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${q}`,
{ cache: "force-cache" }
);
if (!response.ok) {
return <div>오류가 발생했습니다..</div>;
}
...
}
export default function Page({
searchParams,
}: {
searchParams: {
q?: string;
};
}) {
return (
<Suspense key={searchParams.q || ""} fallback={<div>Loading ...</div>}>
<SearchResult q={searchParams.q || ""} />;
</Suspense>
);
}
페이지 스트리밍 시 loading.tsx를 사용하는 대신에 SusPense를 이용해
SearchResult를 Suspense로 묶어서 fallback 시 대체 UI로서 Loading ...이 표시된다.
Suspense 컴포넌트는 한 페이지 내에 비동기 작업이 여러 개가 있을 떄 진가를 발휘한다.
<Suspense
fallback={
<>
<BookItemSkeleton />
</>
}
>
fallback 속성에 대체될 Component를 적어주면 된다.
fetch 호출 시 try-catch 구문으로 전부 에러를 핸들링 하기에는 손이 많이 가는 문제점이 발생한다. 그래서 Next.js에서는 페이지 경로에 error.tsx를 만들어 주면 자동으로 에러를 핸들링 한다.
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.log(error.message);
}, [error]);
return (
<div>
<h3>오류가 발생했습니다.( {error.message} )</h3>
<button
onClick={() => {
startTransition(() => {
router.refresh(); // 현재 페이지에 필요한 서버컴포넌트들을 다시 불러옴
reset(); // 에러 상태를 초기화, 컴포넌트들을 다시 렌더링
});
}}
>
다시 시도
</button>
</div>
);
}
props로 error를 받으면 error의 message 출력이 가능하고,
오류 발생시 router의 refresh함수를 통해서 현재 페이지의 서버컴포넌트의 fetch 데이터를 다시 불러와 데이터를 재랜더링하고 오류 페이지를 없앨 수 있다.
이 때, router.refresh()는 비동기함수인데,reset()이 먼저 실행이 되는 문제가 발생해 이럴 떄는 startTransition을 이용해
순차적으로 실행이 되게끔 콜백함수를 적어주면 된다.
function ReviewEditor() {
async function createReviewAction(formData: FormData) {
// server action
"use server";
const content = formData.get("content")?.toString();
const author = formData.get("author")?.toString();
console.log(content, author);
}
return (
<section>
<form action={createReviewAction}>
<input name="content" placeholder="리뷰 내용" />
<input name="author" placeholder="작성자" />
<button type="submit">작성하기</button>
</form>
</section>
);
}
서버 액션을 만들게되면 자동으로 API가 생성이되고, 브라우저에서 form 태그를 제출했을 떄 자동으로 생성이된다.
"use server";
import { revalidatePath } from "next/cache";
export async function createReviewAction(formData: FormData) {
// server action
const bookId = formData.get("bookId")?.toString();
const content = formData.get("content")?.toString();
const author = formData.get("author")?.toString();
if (!bookId || !content || !author) {
return;
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review/`,
{
method: "POST",
body: JSON.stringify({ bookId, content, author }),
}
);
console.log(response.status);
revalidatePath(`/book/${bookId}`); // 재검증
} catch (err) {
console.error(err);
return;
}
}
현재 서버 액션에서 revalidatePath를 요청하면 Next 서버 측에게
데이터를 다시 요청해서 화면에 바로 나타내게 할 수 있다.
즉, 관련된 페이지가 재생성된다.
useActionState는 폼의 제출 후 상태를 자동으로 관리할 수 있도록 도와줍니다.
Form 태그 제출 시 useActionState를 사용하여 isPending를 통해
요청 중인지 아닌지 상태를 확인할 수 있다. isPending 속성을 이용해
로딩 중인 UI를 표시할 수 있다.
"use client";
import { createReviewAction } from "@/actions/create-review.action";
import style from "./review-editor.module.css";
import { useActionState } from "react";
export default function ReviewEditor({ bookId }: { bookId: string }) {
const [state, formAction, isPending] = useActionState(
createReviewAction, // 서버 액션
null
);
return (
<section>
<form className={style.form_container} action={formAction}>
<input name="bookId" value={bookId} hidden readOnly />
<textarea
disabled={isPending}
required
name="content"
placeholder="리뷰 내용"
/>
<div className={style.submit_container}>
<input
disabled={isPending}
required
name="author"
placeholder="작성자"
/>
<button disabled={isPending} type="submit">
{isPending ? "...." : "작성하기"}
</button>
</div>
</form>
</section>
);
}
먼저, 서버 컴포넌트였던 것들 "use client"를 이용해
클라이언트 컴포넌트로 바꾼다.
const [state, formAction, isPending] = useActionState(
createReviewAction, // 서버 액션
null
);
export async function createReviewAction(_: any, formData: FormData) {
createReviewAction: (필수) 폼 데이터를 받아 처리하는 서버 액션 함수
initialState: (선택) 초기 상태 값 (null 또는 기본 객체 등 사용 가능)
useActionState는 자동으로 이전 상태 값(state)을 첫 번째 매개변수로 전달하기 때문에, createReviewAction 함수에서도 해당 값을 받을 매개변수를 설정해야 합니다. 하지만 이 값을 사용하지 않을 경우 _와 같이 이름을 지정하여 무시할 수 있습니다.
const formRef = useRef<HTMLFormElement>(null);
const [state, formAtion, isPending] = useActionState(
deleteReviewAction,
null
);
<form ref={formRef} action={formAtion}>
<input name="reviewId" value={reviewId} hidden />
<input name="bookId" value={bookId} hidden />
{isPending ? (
<div>...</div>
) : (
<div onClick={() => formRef.current?.requestSubmit()}>삭제하기</div>
)}
</form>
form 태그 내부에 <button>
태그가 아닌 일반 <div>
태그를 사용할 경우, 기본적으로 submit 동작을 수행할 수 없습니다. 이를 해결하기 위해 useRef를 사용하여 requestSubmit()을 호출하면, <button type="submit">
의 역할을 대신할 수 있습니다.
app
라우트 구조와 병렬 라우트 설명위 이미지에서 app
디렉터리 내 parallel
폴더를 보면 병렬 라우트(Parallel Routes) 구조가 적용되어 있다.
특히 parallel
폴더 아래에 @feed
와 @sidebar
라는 디렉터리가 있는데, 이는 병렬 슬롯(Parallel Slot)으로 사용된다.
이러한 구조는 parallel/layout.tsx
파일에서 각각 feed
와 sidebar
로 매핑되어 렌더링된다.
parallel/layout.tsx
)import Link from "next/link";
import { ReactNode } from "react";
export default function Layout({
children,
sidebar,
feed,
}: {
children: ReactNode;
feed: ReactNode;
sidebar: ReactNode;
}) {
return (
<div>
<div>
<Link href={"/parallel"}>parallel</Link>
<Link href={"/parallel/setting"}>parallel/setting</Link>
</div>
{sidebar} {/* 병렬로 렌더링될 sidebar 슬롯 */}
{feed} {/* 병렬로 렌더링될 feed 슬롯 */}
{children} {/* 기본적으로 페이지가 렌더링되는 영역 */}
</div>
);
}
<img src={coverImgUrl}/>
=>
<Image
src={coverImgUrl}
width={240}
height={300}
alt={`도서 ${title}의 표지 이미지`}
/>
export const metadata: Metadata = {
title: "한입 북스",
description: "한입 북스에 등록된 도서를 만나보세요.",
openGraph: {
title: "한입 북스",
description: "한입 북스에 등록된 도서를 만나보세요.",
images: ["/thumbnail.png"],
},
};
//동적으로 meta data를 생성하는 역할
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}): Promise<Metadata> {
const { q } = await Promise.resolve(searchParams); // searchParams를 await 처리
return {
title: `${q} : 한입북스 검색`,
description: `${q}의 검색 결과입니다.`,
openGraph: {
title: `${q} : 한입북스 검색`,
description: `${q}의 검색 결과입니다.`,
images: ["/thumbnail.png"],
},
};
}
npm i -g vercel
vercel login
vercel
vercel --prod
localhost가 아닌 배포된 서버 주소를 입력해 환경 변수를 등록해야한다.