
Next.js는 13버전부터 App Router를 도입하여 라우팅 방식을 대폭 개선.
기존의 페이지 단위 라우팅에서 더 세밀한 제어가 가능해졌고,
서버 컴포넌트, 스트리밍 및 Suspense 등 최신 기술을 자연스럽게 사용할 수 있도록 설계.
이 포스트에서는 Next.js의 App Router를 처음 접하는 개발자를 위해,
라우팅 구조와 각 파일(page.tsx, route.ts 등)의 명확한 역할 및 사용법을 알기 쉽게 정리.
app/
├── layout.tsx
├── page.tsx
├── loading.tsx
├── error.tsx
├── template.tsx
└── api/
└── route.ts
page.tsx (기본 페이지 컴포넌트)'use client'키워드로 클라이언트 구분 가능const Page = () => {
return (
<div> Page </div>
);
}
export default TestPage;
layout.tsx (레이아웃 컴포넌트)const Layout= ({ children }) => {
return (
<>
<header>헤더</header>
{children}
<footer>푸터</footer>
</>
);
}
export default Layout;
route.ts (API 라우팅)// app/api/users/route.ts
import { NextResponse } from 'next/server';
let users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
// GET - 전체 유저 조회
export const GET = async () => {
return NextResponse.json(users);
}
// POST - 새로운 유저 추가
export const POST = async (request: Request) => {
const body = await request.json();
const newUser = {
id: users.length + 1,
name: body.name
};
users.push(newUser);
return NextResponse.json(newUser, { status: 201 });
}
// PUT - 유저 이름 수정 (간단히 id=1만 수정한다고 가정)
export const PUT (request: Request) => {
const body = await request.json();
const user = users.find(u => u.id === body.id);
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
user.name = body.name;
return NextResponse.json(user);
}
// DELETE - 유저 삭제 (id 기반 삭제)
export const DELETE = async (request: Request) => {
const { searchParams } = new URL(request.url);
const id = parseInt(searchParams.get('id') || '0');
const index = users.findIndex(u => u.id === id);
if (index === -1) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const deleted = users.splice(index, 1)[0];
return NextResponse.json(deleted);
}
loading.tsx (로딩 컴포넌트)const Loading = () => {
return <div>Loading...</div>
}
export default Loading;
layout.tsx vs template.tsx| 항목 | layout.tsx | template.tsx |
|---|---|---|
| 위치 | app/경로/layout.tsx | app/경로/template.tsx |
| 재사용 여부 | 유지됨 (경로 변경해도 유지됨) | 유지 안 됨 (경로 변경 시 새로 마운트됨) |
| 용도 | 공통 UI 구성, 상태 유지 | 페이지 전환 효과, 초기화 목적 |
| 적용 대상 | 하위 모든 page.tsx, layout.tsx 등 | 하위 page.tsx 및 그 하위만 감쌈 |
| 상태 유지 | 유지됨 | 안 됨 (컴포넌트 리셋됨) |
error.tsx (에러 컴포넌트)const Error = ({ error, reset }) => {
return (
<div>
<p>에러가 발생했습니다: {error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}
export default Error;
template.tsx (템플릿 컴포넌트)const Template = ({ children }) => {
return <div className="transition-opacity animate-fadeIn">{children}</div>;
}
export default Template;
- URL 예시
/posts/1/posts/hello-world📘 코드 예시
// app/posts/[id]/page.tsx
import { useParams } from 'next/navigation';
const PostPage = () => {
const params = useParams();
return <div>Post ID: {params.id}</div>;
}
export default PostPage;
layout.tsx 안에서 <Slot />으로 활용app/
└── layout.tsx
└── @modal/
└── page.tsx
└── @main/
└── page.tsx
// app/layout.tsx
const RootLayout = ({ modal, main}: {modal: React.ReactNode; main: React.ReactNode }) => {
return (
<>
<div>{main}</div>
<aside>{modal}</aside>
</>
);
}
export default RootLayout;
| 표현 | 의미 | 가로채는 대상 경로 예시 | 설명 |
|---|---|---|---|
(.) | 현재 경로 기준 | /dashboard/(.)modal | dashboard 하위에서만 모달 라우트를 가로챔 |
(..) | 상위 1단계 경로까지 | /dashboard/(..)/modal | dashboard 외부 경로에서도 modal로 접근 가능 |
(...) | 루트 기준, 전체 앱 어디에서든 가로챔 | /dashboard/(...)/modal | 어느 경로에서든 modal 라우트를 인터셉트함 |
예시 구조:
app/
├─ dashboard/
│ ├─ page.tsx
│ └─ (...)/modal/
│ └─ page.tsx
├─ modal/ <-- 일반 경로 (가로채지 않음)
│ └─ page.tsx
├─ layout.tsx
(.)modal/
(..)/modal/
(...)/modal/
인터셉트 라우팅(intercepted routing)으로 설정한 디렉토리(ex - modal)는 Slot으로 동작.
//app/dashboard/(...)/modal/page.tsx
'use client'
import { useParams, useRouter } from 'next/navigation';
const TestModal = () => {
const params = useParams();
const router = useRouter();
const closeModal = () => {
router.back(); // 뒤로 가기 (이전 경로로 복귀)
};
return (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 100,
}}
>
<div
style={{
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
width: '400px',
textAlign: 'center',
}}
>
<h2>모달 테스트</h2>
<p>Modal ID: {params.id}</p>
<button className={'bg-amber-400 rounded-2xl p-2 cursor-pointer'} onClick={closeModal}>닫기</button>
</div>
</div>
);
}
export default TestModal;
//app/layout.tsx - modal 추가
const RootLayout = async ({children, modal} :{children: React.ReactNode, modal: React.ReactNode}) => {
const systemMode = await getSystemMode()
const direction = 'ltr'
const session = await getServerSession(authOptions);
return (
<html id='__next' lang='en' dir={direction} suppressHydrationWarning>
<body className='flex is-full min-bs-full flex-auto flex-col'>
<InitColorSchemeScript attribute='data' defaultMode={systemMode}/>
<SessionProvider session={session}>
{children}
{modal}
</SessionProvider>
</body>
</html>
)
}
export default RootLayout
| 파일명 | 라우팅 역할 | 특징 |
|---|---|---|
page.tsx | 페이지 컴포넌트 라우팅 | 페이지 UI 렌더링, entry-point |
layout.tsx | 공통 레이아웃 구성 | 상태 유지, Nested Layout 가능 |
route.ts | API 라우트 엔드포인트 | HTTP 요청 처리 |
loading.tsx | 로딩 상태 UI 표시 | Suspense 활용한 비동기 처리 지원 |
error.tsx | 에러 UI 표시 및 처리 | 에러 Boundary 역할 |
template.tsx | 페이지 전환 시 상태 초기화 | 페이지 전환 효과(애니메이션 등) |