Parallel Routes, Intercepting Routes
코드는 Next.js 16.3 기준입니다.
사진 모달 하나에 두 기능이 필요합니다
인스타그램 피드에서 사진을 누르면 모달이 뜹니다. 피드는 뒤에 그대로 남아 있고 주소창은 /p/123으로 바뀝니다. 그 주소를 친구에게 보내면 친구는 사진 한 장이 꽉 찬 페이지를 봅니다.
이 동작 하나에 두 기능이 함께 들어갑니다. 맡는 역할이 다릅니다.
| Parallel Routes | Intercepting Routes | |
|---|---|---|
| 하는 일 | 한 layout에 여러 page를 동시에 그립니다 | 다른 경로의 page를 지금 화면에서 엽니다 |
| 표시 | 폴더 이름 앞에 @ | 폴더 이름 앞에 점 괄호 |
| 모달에서 맡는 것 | 모달이 들어갈 자리 | 그 자리에 넣을 내용 |
| URL 영향 | 없습니다 | 없습니다 |
둘 다 URL을 건드리지 않습니다. 괄호 폴더와 같은 성격입니다. 그쪽은 Routing에 정리해 두었습니다.
Parallel Routes
@폴더가 slot을 만듭니다
인스타그램 데스크톱 홈에는 세 덩어리가 한 화면에 같이 있습니다. 가운데 피드, 위쪽 스토리 줄, 오른쪽 추천 계정입니다. 세 덩어리를 한 layout 안에서 동시에 그리는 것이 Parallel Routes입니다.
폴더 이름 앞에 @를 붙이면 slot이 됩니다.
slot은 부모 layout의 props로 들어옵니다. 폴더 이름에서 @를 뗀 것이 prop 이름입니다.
// app/layout.tsx
export default function RootLayout({
children,
stories,
suggestions,
}: {
children: React.ReactNode;
stories: React.ReactNode;
suggestions: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
{stories}
{/* children이 app/page.tsx, 즉 피드다 */}
<main>{children}</main>
{suggestions}
</body>
</html>
);
}
children도 slot입니다. 폴더로 만들지 않아도 이미 있는 것이라 이름을 따로 지을 일이 없습니다.
The
childrenprop is an implicit slot that does not need to be mapped to a folder. This meansapp/page.jsis equivalent toapp/@children/page.js.
slot 이름은 URL에 나타나지 않습니다
app/@stories/highlights/page.tsx를 만들면 열리는 주소는 /highlights입니다. @stories가 경로에서 빠집니다.
However, slots are not route segments and do not affect the URL structure.
같은 레벨의 slot들은 합쳐져 그 경로의 페이지 하나가 됩니다. 그래서 프리렌더링되는 slot과 요청 시점에 렌더링되는 slot을 같은 레벨에 섞어 둘 수 없습니다.
Because of this, you cannot have separate prerendered and dynamically rendered slots at the same route segment level. If one slot is dynamic, all slots at that level must be dynamic.
추천 계정 slot에서 쿠키를 읽으면 스토리와 피드까지 동적 렌더링으로 넘어갑니다. 두 방식의 차이는 렌더링과 Hydration에 있습니다.
같은 URL이어도 이동 방식에 따라 slot이 달라집니다
여기가 가장 헷갈립니다. Next.js는 slot마다 지금 어느 하위 페이지가 열려 있는지 기억해 둡니다. 그 기억이 살아 있는지가 갈림길입니다.
| Soft Navigation | Hard Navigation | |
|---|---|---|
| 언제 | <Link>로 이동할 때 | 새로고침, 주소 직접 입력, 첫 방문 |
| slot 활성 상태 | 기억해 둔 것을 씁니다 | 알 수 없습니다 |
| URL과 안 맞는 slot | 보던 화면이 그대로 남습니다 | default.tsx를 그립니다 |
<Link>로 이동할 때는 부분 렌더링만 일어납니다. URL이 가리키는 slot만 바뀌고 나머지는 건드리지 않습니다. 하이라이트를 보다가 홈으로 돌아와도 스토리 영역은 하이라이트를 계속 보여줍니다. URL과 맞지 않아도 그렇습니다.
새로고침은 사정이 다릅니다.
After a full-page load (browser refresh), Next.js cannot determine the active state for the slots that don't match the current URL.
브라우저가 보낸 것은 주소 하나입니다. 이것만 보고 스토리 영역에 무엇이 열려 있었는지 되살릴 방법이 없습니다.
URL과 맞지 않는 slot은 default.tsx를 그립니다
URL로 맞출 수 없는 slot에 무엇을 그릴지 미리 정해 두는 파일입니다.
// app/@stories/default.tsx
export default function Default() {
return <StoryBar />;
}
children에도 필요합니다. /highlights로 직접 들어온 경우를 보면 이유가 보입니다. 이때 @stories는 하이라이트로 맞지만 children은 맞을 것이 없습니다. app/highlights/page.tsx가 없기 때문입니다.
// app/default.tsx
export default function Default() {
return <Feed />;
}
이 파일을 빼먹으면 그 라우트가 404로 떨어집니다.
If you don't create a
default.jsfor thechildrenslot, it will return a 404 page for the route.
공식문서 두 페이지가 다르게 말합니다. Parallel Routes 페이지는 이렇게 씁니다.
On refresh, Next.js will render a
default.jsfor@analytics. Ifdefault.jsdoesn't exist, a404is rendered instead.
나중에 갱신된 default.js 페이지는 이름 있는 slot에서 에러가 난다고 합니다.
If
default.jsdoesn't exist, an error is returned for named slots (@team,@analytics, etc) and requires you to define adefault.jsin order to continue.
옛 동작을 원하면 notFound()를 부르는 default.tsx를 직접 두라고 안내합니다.
// app/@stories/default.tsx
import { notFound } from 'next/navigation';
export default function Default() {
notFound();
}
이름 있는 slot은 에러, children은 404입니다. 어느 쪽이든 default.tsx를 두면 끝납니다.
Intercepting Routes
점 개수가 몇 단계 위인지 정합니다
폴더 이름 앞에 점 괄호를 붙이면 그 이름의 경로를 가로챕니다. 점 개수로 어디서 찾을지 지정합니다.
| 매처 | 찾는 곳 |
|---|---|
(.) | 같은 레벨의 세그먼트 |
(..) | 한 단계 위 세그먼트 |
(..)(..) | 두 단계 위 세그먼트 |
(...) | app 루트의 세그먼트 |
두 단계 위는 (..)(..)입니다. 점을 세 개로 늘리면 중간을 건너뛰고 app 루트를 가리킵니다.
@slot 폴더는 세그먼트에서 빠집니다
이 대목에서 계산이 어긋납니다. 점 개수를 폴더 개수로 세면 틀립니다.
The
(..)convention is based on route segments, not the file-system. For example, it does not consider@slotfolders in Parallel Routes.
@modal 같은 slot 폴더는 세그먼트에 들어가지 않으니 계단 수에서 빠집니다.
피드를 /feed에 두고 사진 모달을 만드는 경우를 봅니다. 가로채는 파일은 app/feed/@modal/(..)p/[id]/page.tsx이고 원래 페이지는 app/p/[id]/page.tsx입니다.
파일 트리를 올라가면 @modal에서 feed로, 다시 app으로 두 칸입니다. 세그먼트로 세면 @modal이 빠져서 feed 한 칸입니다. 매처가 (..) 하나로 끝나는 이유입니다.
This means that the
photoroute is only one segment level higher, despite being two file-system levels higher.
두 기능을 합쳐 사진 모달 만들기
앞 절은 피드가 /feed에 있는 경우였습니다. 인스타그램 피드는 /에 있습니다. 그래서 p가 같은 레벨이 되고 매처는 (.)입니다.
만들려는 동작이 네 가지입니다. 공식문서가 모달의 흔한 숙제로 꼽는 것들입니다.
- 모달 내용을 URL로 공유합니다.
- 새로고침해도 모달이 닫히지 않고 맥락이 유지됩니다.
- 뒤로 가기를 누르면 이전 라우트로 가지 않고 모달이 닫힙니다.
- 앞으로 가기를 누르면 모달이 다시 열립니다.
폴더 구조
app/
├─ layout.tsx
├─ page.tsx # 피드
├─ @modal/
│ ├─ default.tsx # null을 반환해 닫힌 상태를 만든다
│ └─ (.)p/
│ └─ [id]/
│ └─ page.tsx # 모달 안의 사진
└─ p/
└─ [id]/
└─ page.tsx # 링크로 직접 들어왔을 때의 전체 페이지
p 세그먼트가 app 바로 아래에 있고 @modal은 세그먼트에서 빠집니다. @modal/(.)p가 app 레벨에서 p를 찾으니 점 하나로 맞습니다.
p/[id]/page.tsx - 공유받은 링크가 여는 페이지
링크를 공유받은 사람이 볼 화면입니다. 가로채기와 무관하게 먼저 있어야 합니다.
// app/p/[id]/page.tsx
import { Photo } from '@/app/ui/photo';
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <Photo id={id} />;
}
@modal/default.tsx - 모달이 닫힌 상태
모달이 열려 있지 않은 상태입니다. 피드만 보고 있을 때 @modal 자리에 아무것도 없어야 합니다.
// app/@modal/default.tsx
export default function Default() {
return null;
}
@modal/(.)p/[id]/page.tsx - 가로챈 모달
같은 사진을 모달 껍데기에 담습니다.
// app/@modal/(.)p/[id]/page.tsx
import { Modal } from '@/app/ui/modal';
import { Photo } from '@/app/ui/photo';
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<Modal>
<Photo id={id} />
</Modal>
);
}
<Modal>과 내용을 갈라 둔 이유가 있습니다. 껍데기만 클라이언트 컴포넌트로 두면 안쪽 <Photo>는 서버 컴포넌트로 남습니다.
layout.tsx - modal slot 그리기
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<main>{children}</main>
{modal}
</body>
</html>
);
}
피드의 사진을 <Link>로 감싸면 클릭했을 때 모달이 뜹니다. 같은 주소를 새 탭에서 열면 전체 페이지가 나옵니다.
모달 닫기
router.back()을 부르면 히스토리를 한 칸 되돌려 모달이 닫힙니다.
// app/ui/modal.tsx
'use client';
import { useRouter } from 'next/navigation';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
return (
<div className="overlay" onClick={() => router.back()}>
<div className="sheet">{children}</div>
</div>
);
}
router.back() 대신 <Link>로 닫으면 모달이 그대로 남습니다. 클라이언트 사이드 이동에서는 slot에 맞는 라우트가 없어도 보던 화면이 계속 보이기 때문입니다.
Since client-side navigations to a route that no longer match the slot will remain visible, we need to match the slot to a route that returns
nullto close the modal.
그래서 @modal에 null을 반환하는 라우트를 맞춰 둡니다. /로만 닫으면 app/@modal/page.tsx로 됩니다. 어디로 이동해도 닫히게 하려면 catch-all을 씁니다.
// app/@modal/[...catchAll]/page.tsx
export default function CatchAll() {
return null;
}
slot마다 로딩과 에러를 따로 둡니다
slot은 각자 스트리밍됩니다.
Parallel Routes can be streamed independently, allowing you to define independent error and loading states for each route.
추천 계정 조회가 느려도 피드를 붙잡아 두지 않습니다. slot 폴더 안에 loading.tsx와 error.tsx를 두면 그 slot의 경계가 됩니다.
app/
├─ @stories/
│ ├─ loading.tsx # 스토리만 스켈레톤
│ └─ page.tsx
└─ @suggestions/
├─ error.tsx # 추천만 에러 UI
└─ page.tsx
loading.tsx와 <Suspense>의 범위 차이는 서버 컴포넌트 데이터 조회와 Server Function에 정리해 두었습니다.
조건부로 slot을 골라도 양쪽 다 서버에서 실행됩니다
layout에서 slot을 골라 그리는 것도 됩니다. 프로필 화면에서 내 프로필이면 편집 버튼을, 남의 프로필이면 팔로우 버튼을 보여주는 경우입니다.
// app/[username]/layout.tsx
import { getViewer } from '@/lib/auth';
export default async function ProfileLayout({
params,
owner,
visitor,
}: {
params: Promise<{ username: string }>;
owner: React.ReactNode;
visitor: React.ReactNode;
}) {
const { username } = await params;
const viewer = await getViewer();
return viewer?.username === username ? owner : visitor;
}
layout이 고르지 않은 slot도 서버에서 실행됩니다.
Both slots render on the server, regardless of which one the layout returns. The conditional decides what the user sees, not what runs.
@owner/page.tsx는 남의 프로필을 보는 사람에게도 실행됩니다. 그 결과가 브라우저로 가는 응답에 실립니다. layout의 삼항 연산자는 화면에 무엇을 보여줄지만 정합니다.
권한 확인을 layout에 두면 안 됩니다. 각 slot의 page 안에서 확인하거나 데이터를 읽는 계층에서 확인해야 합니다. 화면에서 버튼을 숨기는 것으로 Server Function의 권한을 대신하지 못하는 것과 같은 이야기입니다.
정리
Parallel Routes
@폴더가 slot이 되고 부모 layout의 props로 들어옵니다.children도 slot입니다.- slot 이름은 URL에 나타나지 않습니다. 같은 레벨의 slot은 프리렌더링과 동적 렌더링을 섞지 못합니다.
<Link>이동에서는 URL과 안 맞는 slot도 보던 화면이 남습니다. 새로고침에서는default.tsx가 그려집니다.default.tsx를 빼먹으면 이름 있는 slot은 에러,children은 404입니다.- slot마다
loading.tsx와error.tsx를 따로 둡니다.
Intercepting Routes
(.)는 같은 레벨,(..)는 한 단계 위,(..)(..)는 두 단계 위,(...)는app루트입니다.- 세는 대상은 세그먼트입니다.
@modal같은 slot 폴더는 계단 수에서 빠집니다.
둘을 합칠 때
@modal/default.tsx는null을 반환해 닫힌 상태를 맡습니다. 모달 화면은@modal/(.)p/[id]/page.tsx가 만듭니다.- 원래 경로의
page.tsx는 그대로 둡니다. 링크를 공유받은 사람이 보는 화면입니다. router.back()으로 닫습니다.<Link>로 닫으려면[...catchAll]로null을 맞춰 둡니다.- 조건부로 slot을 골라도 안 고른 쪽이 서버에서 실행됩니다. 권한은 slot 안에서 확인합니다.
참고 자료
- Next.js - Parallel Routes
- Next.js - Intercepting Routes
- Next.js - default.js
- nextgram - 인스타그램식 사진 모달 예제
- Routing - 폴더와 파일 컨벤션, Route Groups
- 렌더링과 Hydration - 프리렌더링과 동적 렌더링의 차이
- 서버 컴포넌트 데이터 조회와 Server Function - loading.tsx와 Suspense의 범위