본문으로 건너뛰기

Layouts and Pages, Linking and Navigating, Error Handling, Redirecting, Route Groups

코드는 Next.js 16.3 기준입니다.


폴더 이름이 URL을 만듭니다

폴더 이름을 어떻게 짓느냐에 따라 세 가지로 갈립니다.

폴더 이름규칙URL
blog이름 그대로 세그먼트가 됩니다/blog
[slug]값이 들어가는 동적 세그먼트가 됩니다/blog/hello
(marketing)URL에 나타나지 않습니다. 묶기 위한 폴더입니다경로에서 사라집니다

왼쪽에 app 폴더 아래 blog와 slug 폴더가 중첩된 트리를 두고, 오른쪽에 각 page 파일이 만들어 내는 URL을 화살표로 이어 표시한 그림. layout.tsx는 경로를 만들지 않는다

폴더를 만들었다고 그 경로가 열리지는 않습니다. page 파일(또는 API를 반환하는 route 파일)이 있어야 접근할 수 있습니다. layout.tsx는 아무리 많아도 경로를 만들지 않습니다.

그래서 app 안에 컴포넌트나 유틸을 같이 두어도 라우팅되지 않습니다. 클라이언트로 나가는 것은 pageroute가 반환한 내용뿐입니다.

괄호 폴더로 URL 없이 묶기 (Route Groups)

폴더 이름을 괄호로 감싸면 그 이름은 경로에서 빠집니다.

왼쪽에 app 아래 (marketing)과 (shop) 그룹 폴더가 있는 트리를 두고, 오른쪽에 만들어지는 URL을 표시해 괄호 폴더 이름이 경로에서 사라지는 것을 보여주는 그림. (shop) 안의 layout.tsx는 그 그룹의 페이지에만 적용된다

쓰는 자리는 세 가지입니다.

  • 팀, 관심사, 기능 단위로 라우트를 묶어 정리합니다.
  • 여러 개의 root layout을 정의합니다.
  • 특정 세그먼트만 layout을 공유하게 하고 나머지는 빼 둡니다.

세 번째가 가장 자주 쓰입니다. /cart/checkout은 쇼핑 layout을 쓰고 /about은 안 쓰게 하고 싶은데, URL에 shop 세그먼트는 넣기 싫을 때입니다.

주의

전체 페이지 리로드. 서로 다른 root layout을 쓰는 라우트 사이를 이동하면 전체 페이지가 다시 로드됩니다. root layout이 여러 개일 때만 해당하고, 뒤에 나오는 클라이언트 사이드 전환의 이점이 여기서는 사라집니다.

경로 충돌. (marketing)/about/page.tsx(shop)/about/page.tsx는 둘 다 /about이 되므로 에러입니다.

최상위 layout. 최상위 layout.tsx 없이 여러 root layout을 쓴다면 홈 라우트(/)가 그룹 중 하나 안에 있어야 합니다.


파일 이름이 화면을 만듭니다

폴더가 경로를 정했다면, 그 폴더 안에 어떤 이름의 파일을 두느냐가 화면을 정합니다. 이름이 곧 역할입니다.

파일하는 일
page.tsx그 경로의 화면입니다. 이 파일이 있어야 경로가 열립니다
route.ts화면 대신 API 응답을 반환합니다. 이 파일도 경로를 엽니다
layout.tsx여러 페이지가 공유하는 UI입니다. 이동해도 유지됩니다
loading.tsxpage가 준비될 때까지 대신 보여줄 UI입니다
error.tsx안쪽에서 던진 에러를 잡는 경계입니다
not-found.tsxnotFound()가 호출됐을 때 보여줄 화면입니다
global-error.tsxroot layout이나 template에서 난 에러를 받습니다. app 최상단에만 둡니다
proxy.ts요청이 렌더링에 닿기 전에 가로챕니다. 프로젝트 루트에 둡니다 (구 middleware.ts)

이 파일들은 나란히 놓이는 게 아니라 서로를 감쌉니다.

layout.tsx가 error.tsx를 감싸고 error.tsx가 loading.tsx를, loading.tsx가 page.tsx를 감싸는 네 겹 구조를 왼쪽에 두고 오른쪽에 각 파일의 역할을 적은 그림. loading.tsx는 page만 덮고 layout은 계속 보인다

이 순서에서 세 가지가 따라 나옵니다.

  • loading.tsxpage만 덮습니다. 로딩 중에도 layout은 그대로 보이고 계속 눌립니다.
  • error.tsx는 안쪽에 있는 것들이 던진 에러를 잡습니다.
  • error.tsxlayout.tsx 안쪽에 있습니다. 그래서 같은 세그먼트의 layout이 던진 에러는 잡지 못합니다.

공식문서가 error.js의 범위를 이렇게 못박습니다.

In the component hierarchy, error.js wraps loading.js, not-found.js, page.js, and nested layout.js files in a React error boundary. It does not wrap the layout.js or template.js above it in the same segment.

하위 세그먼트의 layout까지 잡지만, 같은 세그먼트의 layouttemplate은 잡지 않습니다.

참고

위 그림은 자주 쓰는 네 개만 그렸습니다. 공식문서가 정의하는 전체 순서는 여섯 단계입니다.

layout.js
└─ template.js
└─ error.js
└─ loading.js
└─ not-found.js
└─ page.js 또는 하위 layout.js

폴더가 중첩되면 이 스택이 겹칩니다

app/blog/[slug]/처럼 폴더가 중첩되면 위 스택이 세그먼트마다 반복되고, 바깥 layout이 안쪽 전체를 감쌉니다.

// app/blog/layout.tsx
export default function BlogLayout({
children,
}: {
children: React.ReactNode;
}) {
return <section>{children}</section>;
}

app/layout.tsx가 app/blog/layout.tsx를 감싸고 그 안에 page.tsx가 들어가는 세 겹 구조를 왼쪽에 두고, 오른쪽에 이동할 때 바깥 두 겹은 그대로 남고 안쪽 page만 교체되는 모습을 그린 그림

/blog에서 /blog/hello로 갈 때 바깥 두 겹은 그대로 남고 안쪽 page만 바뀝니다. 공식문서가 layout을 이렇게 정의합니다.

On navigation, layouts preserve state, remain interactive, and do not rerender.

이동해도 상태가 유지되고, 상호작용이 살아 있고, 다시 렌더링되지 않는다는 뜻입니다.

app 최상단의 layout을 root layout이라고 합니다. 필수이고 htmlbody 태그를 반드시 포함해야 합니다.

// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{/* children 자리에 page나 하위 layout이 들어온다 */}
<main>{children}</main>
</body>
</html>
);
}

page가 받는 값

[slug] 같은 동적 세그먼트의 값은 params로, 쿼리스트링은 searchParams로 들어옵니다. 둘 다 Promise라 await으로 꺼냅니다.

// app/blog/[slug]/page.tsx
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);

return <h1>{post.title}</h1>;
}
주의

searchParams를 쓰면 그 페이지는 동적 렌더링으로 넘어갑니다. 쿼리스트링을 읽으려면 들어온 요청이 있어야 하기 때문입니다.

빌드 시점에 미리 만들어 둘 수 없다는 뜻이라, 프리렌더링을 기대한 페이지였다면 의도한 결과인지 확인해야 합니다. 두 방식의 차이는 렌더링과 Hydration에 정리해 두었습니다.

쿼리를 읽는 방법이 셋인데, 그 값으로 무엇을 하느냐로 고릅니다.

쓰는 것쓰는 상황
searchParams prop쿼리로 페이지의 데이터를 불러와야 할 때 씁니다
useSearchParams쿼리가 클라이언트에서만 쓰일 때 씁니다
new URLSearchParams(window.location.search)이벤트 핸들러에서 리렌더 없이 읽을 때 씁니다
참고

PagePropsLayoutProps는 라우트 구조에서 params 타입을 추론해 주는 전역 헬퍼입니다. import 없이 쓰고, next dev / next build / next typegen 때 생성됩니다.

// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params;
return <h1>Blog post: {slug}</h1>;
}

화면 사이를 오갑니다

라우트는 기본적으로 서버에서 렌더링됩니다. 그러면 새 화면을 보려면 서버 응답을 기다려야 합니다. 이 대기를 가리려고 Next.js가 prefetching, streaming, client-side transitions 세 가지를 씁니다.

언제 렌더링되는지에 따라 프리페치가 갈립니다

언제 일어나는가결과
Prerendering빌드 시점 또는 재검증 시점캐시됩니다
Dynamic Rendering요청이 들어온 시점매 요청마다 만듭니다

<Link>로 연결한 라우트는 링크가 뷰포트에 들어오거나 호버될 때 자동으로 프리페치됩니다. <a>는 아무것도 하지 않습니다.

// app/layout.tsx
<nav>
{/* 호버하거나 뷰포트에 들어오면 프리페치된다 */}
<Link href="/blog">Blog</Link>
{/* 프리페치 없음 */}
<a href="/contact">Contact</a>
</nav>

얼마나 프리페치되는지가 위 표에 따라 달라집니다.

라우트프리페치 범위
Static Route라우트 전체를 프리페치합니다
Dynamic Route건너뜁니다. 단 loading.tsx가 있으면 부분 프리페치합니다

동적 라우트를 건너뛰는 이유는 방문하지 않을 수도 있는 라우트를 위해 서버가 미리 일하지 않기 위해서입니다. 대가로 클릭한 뒤 서버 응답을 기다리는 동안 앱이 멈춘 것처럼 보입니다.

loading.tsx 파일 하나가 하는 일

loading.tsx를 두면 Next.js가 page.tsx<Suspense>로 자동으로 감싸고, 동적 라우트도 부분 프리페치가 켜집니다.

// app/dashboard/loading.tsx
export default function Loading() {
return <LoadingSkeleton />;
}

클릭한 순간부터 화면이 보이기까지를 두 레인으로 비교한 그림. loading.tsx가 없으면 서버 응답이 올 때까지 이전 화면에 머물러 클릭이 먹지 않은 것처럼 보이고, loading.tsx가 있으면 클릭 즉시 스켈레톤으로 전환된 뒤 본문이 스트리밍으로 채워진다

서버가 걸리는 시간은 양쪽이 같습니다. 그 구간을 무엇으로 채우는지가 다릅니다. 파일 하나로 얻는 것이 세 가지입니다.

  • 즉시 이동하고 즉시 시각적 피드백을 줍니다.
  • 공유 layout이 계속 상호작용 가능하고, 이동을 도중에 취소할 수 있습니다.
  • TTFB, FCP, TTI가 개선됩니다.

loading.tsx<Suspense>의 범위 차이는 서버 컴포넌트 데이터 조회와 Server Function에 있습니다.

클라이언트 사이드 전환

<Link>는 페이지를 다시 로드하지 않고 콘텐츠만 바꿉니다. 공유 layout과 UI를 그대로 두고, page만 프리페치해 둔 로딩 상태나 새 page로 교체합니다.

이동할 때 스크롤을 맨 위로 올리는 것도 Next.js가 처리합니다. sticky 헤더 뒤로 콘텐츠가 숨으면 CSS scroll-padding-top으로 조정합니다.

그런데도 느리게 느껴진다면

원인증상해결
동적 라우트에 loading.tsx가 없습니다클릭해도 한동안 반응이 없습니다loading.tsx를 둡니다
동적 세그먼트에 generateStaticParams가 없습니다프리렌더될 수 있는데 요청마다 렌더링합니다generateStaticParams를 추가합니다
네트워크가 느립니다프리페치가 클릭 전에 끝나지 않습니다useLinkStatus로 즉시 피드백을 줍니다
Hydration이 아직 안 끝났습니다초기 방문에서 프리페치가 시작되지 않습니다번들을 줄이고 로직을 서버로 옮깁니다

프리렌더될 수 있는 동적 세그먼트인데 generateStaticParams가 없으면 요청 시점 렌더링으로 넘어갑니다.

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json());

return posts.map((post) => ({ slug: post.slug }));
}

느린 네트워크에서는 클릭 전에 프리페치가 끝나지 않아 loading.tsx fallback조차 곧바로 나타나지 않습니다. useLinkStatus로 진행 중 표시를 줍니다.

// app/ui/loading-indicator.tsx
'use client';

import { useLinkStatus } from 'next/link';

export default function LoadingIndicator() {
const { pending } = useLinkStatus();
return (
<span aria-hidden className={`link-hint ${pending ? 'is-pending' : ''}`} />
);
}

빠른 이동에서 깜빡이는 것을 막으려면 시작을 opacity: 0으로 두고 애니메이션에 100ms 정도 지연을 줍니다. 그보다 오래 걸리는 이동에서만 표시가 나타납니다.

링크가 아주 많은 화면에서는 prefetch={false}로 프리페치를 끕니다. 대신 정적 라우트는 클릭한 뒤에야 가져오고, 동적 라우트는 클릭한 뒤 서버 렌더링을 기다립니다. 완전히 끄는 대신 prefetch={active ? null : false}onMouseEnter를 조합해 호버할 때만 프리페치하는 절충안이 있습니다.

화면은 그대로 두고 URL만 바꾸기

window.history.pushStatereplaceState가 Next.js 라우터에 통합되어 있어서 usePathname, useSearchParams와 동기화됩니다.

  • pushState는 히스토리에 새 항목을 추가합니다. 목록 정렬처럼 뒤로 가기로 돌아올 수 있어야 하는 조작에 씁니다.
  • replaceState는 현재 항목을 교체합니다. 로케일 전환처럼 돌아갈 이유가 없는 조작에 씁니다.
// app/ui/sort-products.tsx
'use client';

import { useSearchParams } from 'next/navigation';

export default function SortProducts() {
const searchParams = useSearchParams();

function updateSorting(sortOrder: string) {
const params = new URLSearchParams(searchParams.toString());
params.set('sort', sortOrder);
window.history.pushState(null, '', `?${params.toString()}`);
}

return <button onClick={() => updateSorting('asc')}>Sort Ascending</button>;
}

잘못됐을 때 보여줄 화면

에러는 두 갈래로 나뉩니다. 어느 쪽인지에 따라 다루는 방법이 완전히 달라집니다.

예상된 에러와 잡히지 않은 예외를 두 갈래로 나눈 그림. 왼쪽은 폼 검증 실패 같은 예상된 에러를 반환값으로 돌려보내 폼 안에 메시지로 표시하고, 오른쪽은 예상 못 한 예외를 throw해서 가장 가까운 error.tsx가 대체 UI를 보여주는 흐름이다

왼쪽은 정상 동작의 일부라 값으로 다루고, 오른쪽은 버그라 던집니다.

예상된 에러는 값으로 넘깁니다

Server Function에서 try/catch로 던지지 말고 반환값으로 모델링합니다.

// app/actions.ts
'use server';

export async function createPost(prevState: any, formData: FormData) {
const res = await fetch('https://api.vercel.app/posts', {
method: 'POST',
body: { title: formData.get('title') },
});

if (!res.ok) {
// 던지지 않고 값으로 돌려준다
return { message: 'Failed to create post' };
}
}

useActionState에 넘기면 반환값이 state로 들어옵니다.

// app/ui/form.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '@/app/actions';

export function Form() {
const [state, formAction, pending] = useActionState(createPost, {
message: '',
});

return (
<form action={formAction}>
<input type="text" name="title" required />
{state?.message && <p aria-live="polite">{state.message}</p>}
<button disabled={pending}>Create Post</button>
</form>
);
}

서버 컴포넌트라면 응답을 보고 조건부로 에러 메시지를 그리거나 redirect합니다. 리소스가 아예 없을 때는 notFound()를 호출하고 같은 세그먼트에 not-found.tsx를 둡니다.

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getPostBySlug(slug);

if (!post) {
notFound();
}

return <div>{post.title}</div>;
}

예상 못 한 예외는 error.tsx가 받습니다

세그먼트에 error.tsx를 두면 그 세그먼트의 경계가 됩니다.

// app/dashboard/error.tsx
'use client'; // error boundary는 반드시 클라이언트 컴포넌트다

export default function ErrorPage({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => retry()}>Try again</button>
</div>
);
}
보충 - reset과 retry는 다른 함수입니다

검색하면 나오는 글 대부분이 두 번째 prop을 reset으로 쓰고 있습니다. retry가 나중에 추가된 것이라서인데, resetretry로 이름만 바뀐 것이 아니라 하는 일이 다릅니다.

하는 일
retry()경계 안쪽을 다시 가져와서 다시 렌더링합니다
reset()다시 가져오지 않고 에러 상태만 지우고 다시 렌더링합니다

reset은 지금도 남아 있습니다. 다만 공식문서가 대부분의 경우 retry를 쓰라고 권합니다.

In most cases, you should use retry() instead. However, if you have a specific reason to clear the error state and re-render the error boundary's children without re-fetching the contents, you can use the reset() function.

retryv16.2.0unstable_retry로 추가돼 v16.3.0에서 안정화됐습니다. 그 이전 버전을 쓴다면 reset만 있습니다.

에러는 가장 가까운 부모 경계로 올라갑니다. 어느 세그먼트에 error.tsx를 두느냐가 곧 처리 범위입니다.

에러가 계층을 따라 올라가는 그림. page에서 던진 에러는 같은 세그먼트의 error.tsx가 잡지만, layout 자신이 던진 에러는 그 error.tsx가 잡지 못하고 부모 세그먼트의 error.tsx로 올라간다

같은 세그먼트의 layout.tsx가 던진 에러는 그 세그먼트의 error.tsx가 잡지 못합니다. error.tsx가 layout 안쪽에 놓이기 때문에 그 에러는 한 단계 위로 올라갑니다.

컴포넌트 단위로 경계를 두고 싶다면 catchError로 트리의 아무 위치나 감쌀 수 있습니다.

// app/custom-error-boundary.tsx
'use client';

import { catchError, type ErrorInfo } from 'next/error';

function ErrorFallback(props: { title: string }, { error, retry }: ErrorInfo) {
return (
<div>
<h2>{props.title}</h2>
<button onClick={() => retry()}>Try again</button>
</div>
);
}

export default catchError(ErrorFallback);
주의

error boundary는 렌더링 중에 발생한 에러만 잡습니다. 이벤트 핸들러와 비동기 코드는 렌더링이 끝난 뒤에 실행되므로 잡히지 않습니다. 이 경우는 직접 잡아 useState에 담고 UI를 갱신해야 합니다.

예외가 하나 있습니다. useTransitionstartTransition 안에서 던진 에러는 가장 가까운 경계로 올라갑니다.

계층 맨 위, root layout이나 template에서 난 에러는 app 최상단의 global-error.tsx가 받습니다. root layout을 통째로 대체하므로 htmlbody를 직접 넣어야 합니다. 전역 스타일도 딸려 오지 않습니다. 앱의 테마를 맞추려면 이 컴포넌트 안에서 직접 적용해야 합니다.

// app/global-error.tsx
'use client';

export default function GlobalError({ error, retry }) {
return (
// global-error는 html과 body를 포함해야 한다
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => retry()}>Try again</button>
</body>
</html>
);
}

다른 곳으로 보냅니다

리다이렉트 방법이 다섯 가지인데, 다섯 개를 외우기보다 언제 개입하는가로 갈라 두면 고르기 쉽습니다.

요청이 들어와서 화면이 나가기까지의 시간 축에 리다이렉트 다섯 가지를 배치한 그림. 요청 직후 next.config.js의 redirects가 먼저 걸리고, 다음으로 proxy의 NextResponse.redirect가, 그다음 렌더링 중에 redirect와 permanentRedirect가, 마지막으로 브라우저에서 useRouter가 동작한다

실행 순서는 redirectsproxy → 렌더링입니다.

API쓸 자리상태 코드
redirects in next.config.js미리 알고 있는 경로 목록. URL 구조를 바꿨을 때307 또는 308
NextResponse.redirect in proxy.ts요청마다 판단해야 할 때. 인증, 세션임의
redirect변경 작업 뒤에 보낼 때307 또는 303
permanentRedirect엔티티의 정식 URL 자체가 바뀌었을 때308
useRouter().push클라이언트 이벤트 핸들러 안에서없음

redirect와 permanentRedirect

Server Component, Route Handler, Server Function에서 호출합니다.

// app/actions.ts
'use server';

import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';

export async function createPost(id: string) {
// DB 호출

revalidatePath('/posts'); // 캐시된 목록을 갱신한다
redirect(`/post/${id}`); // 새 글 페이지로 보낸다
}
주의

redirect는 내부적으로 에러를 던져서 동작합니다. try/catch를 쓸 때는 반드시 try 블록 바깥에서 호출해야 합니다. 안에서 부르면 catch가 삼켜 버려 리다이렉트가 일어나지 않습니다.

이벤트 핸들러에서도 쓸 수 없습니다. 클라이언트 컴포넌트에서는 렌더링 과정 중에만 호출할 수 있고, 이벤트 핸들러에서는 useRouter를 써야 합니다.

permanentRedirect는 쓰는 자리가 같고 상태 코드가 308입니다. 사용자가 아이디를 바꿔 프로필 주소가 달라지는 경우처럼 주소 자체가 영구히 이동했을 때 씁니다.

next.config.js의 redirects

// next.config.ts
const nextConfig: NextConfig = {
async redirects() {
return [
{ source: '/about', destination: '/', permanent: true },
// 와일드카드 경로 매칭
{ source: '/blog/:slug', destination: '/news/:slug', permanent: true },
];
},
};

permanent 값에 따라 307 또는 308이 나갑니다. 플랫폼에 개수 제한이 있을 수 있습니다. Vercel은 1,024개입니다.

proxy.ts

요청이 렌더링에 닿기 전에 가로챕니다. 인증처럼 요청마다 판단해야 하는 리다이렉트가 여기입니다.

// proxy.ts
import { NextResponse, NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
// 인증되어 있으면 그대로 진행한다
if (authenticate(request)) {
return NextResponse.next();
}

return NextResponse.redirect(new URL('/login', request.url));
}

export const config = {
matcher: '/dashboard/:path*',
};
보충 - proxy는 middleware의 새 이름입니다

하는 역할은 같습니다. Next.js 16에서 middleware.tsproxy.ts로, export 하는 함수 이름도 middleware에서 proxy로 바뀌었습니다. skipMiddlewareUrlNormalize 같은 설정 플래그도 skipProxyUrlNormalize로 따라 바뀌었습니다. codemod가 있습니다.

npx @next/codemod@canary middleware-to-proxy .

다만 이름만 바뀐 것은 아닙니다. proxy는 Node.js 런타임 고정이고 runtime 설정을 쓸 수 없습니다. 설정하면 에러가 납니다. Edge 런타임이 필요하면 아직 middleware를 써야 합니다.

이름을 바꾼 이유는 Express의 미들웨어와 혼동돼 과하게 쓰이기 때문이라고 밝히고 있습니다. 앱 앞단의 네트워크 경계라는 성격을 이름에 담은 것이고, 공식문서는 다른 방법이 없을 때 마지막 수단으로 쓰라고 권합니다.

주의

Server Function은 별도의 라우트가 아니라 그것이 쓰인 경로로 가는 POST 요청입니다. 그래서 matcher가 어떤 경로를 제외하면 그 경로의 Server Function 호출도 같이 빠집니다. matcher를 고치거나 Server Function을 다른 라우트로 옮기는 것만으로 인증이 조용히 사라질 수 있습니다.

Proxy에만 기대지 말고 각 Server Function 안에서 권한을 확인해야 합니다.


정리

폴더 규칙

  • blog는 세그먼트, [slug]는 동적 세그먼트, (marketing)은 URL에 나타나지 않는 그룹입니다.
  • 폴더만 있으면 경로가 열리지 않습니다. page 파일이 있어야 합니다.

파일 규칙

  • 한 세그먼트 안에서 layouterrorloadingpage 순으로 감쌉니다.
  • 그래서 loadingpage만 덮고, error는 자기 layout을 잡지 못하며, 폴더가 중첩되면 이 스택이 겹칩니다.
  • paramssearchParams는 모두 Promise입니다. searchParams를 쓰면 동적 렌더링이 됩니다.

이동

  • 정적 라우트는 전체가 프리페치되고 동적 라우트는 건너뜁니다. loading.tsx를 두면 부분 프리페치가 켜집니다.
  • 전환이 느리게 느껴지면 loading.tsx, generateStaticParams, useLinkStatus, 번들 크기 넷을 순서대로 봅니다.

에러와 리다이렉트

  • 예상된 에러는 반환값으로, 잡히지 않은 예외는 throw로 다룹니다.
  • 리다이렉트는 redirectsproxy → 렌더링 중 redirect → 브라우저 useRouter 순으로 개입 시점이 다릅니다.
  • redirect는 에러를 던져 동작하므로 try 블록 바깥에서 호출합니다.

참고 자료