Next.js App Router에서 loading.tsx는 같은 세그먼트의 page와 그 아래(하위 layout 포함)를 Suspense로 감싸지만, 같은 세그먼트의 layout은 감싸지 않는다. 그래서 데이터를 기다릴 필요가 없는 공통 UI(탭바, 필터 헤더)를 page에서 layout으로 끌어올리면 스켈레톤에 덮이지 않고 처음부터 실제 모습으로 보인다.
app/shop/
layout.tsx ← 탭바: loading 바깥, 이동해도 유지
loading.tsx ← 스켈레톤: page 자리만 덮음
page.tsx ← 느린 데이터- 탭바가 page 안에 있으면 라우트에 들어오거나 탭을 바꿀 때마다 "탭바 스켈레톤 → 실제 라벨"로 깜빡인다. 라벨이 고정이거나 URL만으로 정해지는 UI라면 기다릴 이유가 없다
- layout은 이동해도 다시 마운트되지 않으니 탭 사이를 오갈 때 공통 UI가 그대로 남고,
loading이 덮는 영역도 콘텐츠로 줄어든다 - 스켈레톤이 아니라 실제 텍스트를 fallback으로 그리면 교체가 눈에 덜 띈다
공유할 라우트만 묶기: 라우트 그룹
괄호로 감싼 폴더 (main)은 URL에 나타나지 않는 라우트 그룹(Route Group)이다. 같은 탭바를 쓰는 라우트들만 app/(main)/ 아래에 두고 app/(main)/layout.tsx에서 한 번 그리면 된다. 목록에는 필요하고 상세에는 필요 없는 헤더라면 app/products/(list)/layout.tsx처럼 목록만 그룹으로 감싸 형제 [id]에는 주입되지 않게 한다. 서로 다른 그룹이 같은 URL로 풀리면 에러다.
주의할 점
- layout은 이동 때 다시 렌더되지 않는다. 그래서 layout은 현재 경로나 쿼리를 받지 못한다. 활성 탭 표시는 클라이언트 컴포넌트에서
usePathname()으로 판단한다. 쿼리스트링이 필요 없다면useSearchParams()를 피한다. 미리 렌더되는 라우트에서 CSR bailout을 부른다 - 경로 매칭은 세그먼트 경계를 지킨다.
pathname.startsWith('/best')는 나중에 생긴/best-of-week도 잡는다.pathname === '/best' || pathname.startsWith('/best/')로 쓴다 - layout이 런타임 데이터를 읽으면
loading이 그 부분을 덮지 못한다.cookies(),headers(), 캐시되지 않은 fetch가 layout에 있으면 Cache Components가 꺼져 있을 때는 layout이 끝날 때까지 이동이 막히고, 켜져 있을 때는 그 접근을 따로<Suspense>로 감싸야 하며 그렇지 않으면 빌드 에러로 알려 준다. 끌어올린 UI가 이런 데이터를 쓰면 캐시하거나("use cache"와 Cache Components) 자체 경계를 둔다 loading경계로 스트리밍이 이미 시작된 뒤 page에서redirect()하면 응답 헤더가 나간 뒤라 상태 코드로 보낼 수 없고 스트림 안에서 이동시킨다. 그 사이 layout과 스켈레톤이 잠깐 보일 수 있으니, 리다이렉트 판단은 경계보다 먼저(첫await전) 한다
클라이언트 이동은 현재 라우트와 목적지가 공유하는 layout 아래만 다시 렌더하므로, 공유 layout보다 위에 있는 Suspense fallback은 이동 중에 쓰이지 않는다. 이동이 즉시 보이는지는 공유 layout 아래의 경계와 캐시에 달렸다(부분 사전 렌더링(PPR)의 instant 검증).
Next.js 16.3 문서 기준이다(2026). 스트리밍 원리는 스트리밍 SSR, 정적 셸에 무엇이 들어가는지는 부분 사전 렌더링(PPR).
출처: Next.js - loading.js · Next.js - Route Groups · Next.js - layout.js: Pathname · Next.js - Ensuring instant navigations