Per-page Layouts
Next.js에서 페이지마다 다른 레이아웃을 적용하는 패턴이다. _app.tsx에서 모든 페이지에 동일한 레이아웃을 적용하는 대신, 각 페이지가 자신만의 레이아웃을 정의할 수 있다.
왜 필요한가?
일반적으로 _app.tsx에서 레이아웃을 적용하면 모든 페이지가 동일한 레이아웃을 사용한다.
// pages/_app.tsx
export default function App({ Component, pageProps }: AppProps) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
하지만 실제 애플리케이션에서는 페이지마다 다른 레이아웃이 필요한 경우가 많다.
- 홈페이지는 헤더만 있고 사이드바가 없다
- 대시보드는 사이드바가 있다
- 관리자 페이지는 다른 네비게이션을 사용한다
- 로그인 페이지는 레이아웃이 아예 없다
Per-page Layouts 패턴을 사용하면 각 페이지가 필요한 레이아웃을 자유롭게 선택할 수 있다.
기본 구조
페이지 컴포넌트에 getLayout 메서드를 추가하고, _app.tsx에서 이를 호출하는 방식이다.
1. _app.tsx 설정
// pages/_app.tsx
import type { AppProps } from 'next/app';
import type { ReactElement, ReactNode } from 'react';
import type { NextPage } from 'next';
type NextPageWithLayout = NextPage & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
export default function App({ Component, pageProps }: AppPropsWithLayout) {
// getLayout이 있으면 사용, 없으면 페이지 그대로 렌더링
const getLayout = Component.getLayout ?? ((page) => page);
return getLayout(<Component {...pageProps} />);
}
위 코드에서 Component.getLayout이 있는지 확인한다. 있으면 해당 함수를 호출하여 페이지를 레이아웃으로 감싸고, 없으면 페이지를 그대로 렌더링한다.
?? 연산자는 왼쪽 값이 null 또는 undefined일 때 오른쪽 값을 사용한다.
2. 레이아웃이 필요한 페이지
// pages/dashboard.tsx
import { ReactElement } from 'react';
import DashboardLayout from '@/components/DashboardLayout';
export default function Dashboard() {
return (
<div>
<h1>대시보드</h1>
<p>통계와 차트가 여기에 표시된다</p>
</div>
);
}
Dashboard.getLayout = function getLayout(page: ReactElement) {
return <DashboardLayout>{page}</DashboardLayout>;
};
페이지 컴포넌트에 getLayout 메서드를 추가한다. 이 메서드는 페이지를 받아서 레이아웃으로 감싼 결과를 반환한다.
3. 레이아웃이 없는 페이지
// pages/login.tsx
export default function Login() {
return (
<div>
<h1>로그인</h1>
<form>{/* 로그인 폼 */}</form>
</div>
);
}
// getLayout을 정의하지 않으면 레이아웃 없이 페이지만 렌더링된다
getLayout을 정의하지 않으면 _app.tsx에서 (page) => page가 사용되어 페이지가 그대로 렌더링된다.
레이아웃 컴포넌트 작성
레이아웃 컴포넌트는 일반적인 React 컴포넌트다.
// components/DashboardLayout.tsx
import Link from 'next/link';
import { ReactNode } from 'react';
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<div>
<nav>
<Link href="/dashboard">대시보드</Link>
<Link href="/dashboard/analytics">분석</Link>
<Link href="/dashboard/settings">설정</Link>
</nav>
<main>{children}</main>
</div>
);
}
children prop으로 페이지 컴포넌트를 받아서 원하는 위치에 렌더링한다.
중첩 레이아웃
레이아웃 안에 또 다른 레이아웃을 중첩할 수 있다.
// pages/dashboard/analytics.tsx
import { ReactElement } from 'react';
import DashboardLayout from '@/components/DashboardLayout';
import AnalyticsLayout from '@/components/AnalyticsLayout';
export default function Analytics() {
return <div>분석 페이지 내용</div>;
}
Analytics.getLayout = function getLayout(page: ReactElement) {
return (
<DashboardLayout>
<AnalyticsLayout>{page}</AnalyticsLayout>
</DashboardLayout>
);
};
위 코드는 DashboardLayout 안에 AnalyticsLayout을 중첩한다. 페이지 전환 시 DashboardLayout의 상태는 유지되고 AnalyticsLayout만 교체된다.
레이아웃 상태 유지
Per-page Layouts의 중요한 장점은 페이지 전환 시 레이아웃의 상태가 유지된다는 점이다.
// components/DashboardLayout.tsx
import { useState } from 'react';
import Link from 'next/link';
export default function DashboardLayout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
사이드바 토글
</button>
{sidebarOpen && (
<nav>
<Link href="/dashboard">대시보드</Link>
<Link href="/dashboard/analytics">분석</Link>
</nav>
)}
<main>{children}</main>
</div>
);
}
사용자가 사이드바를 닫은 상태에서 /dashboard에서 /dashboard/analytics로 이동해도 사이드바는 닫힌 상태로 유지된다. 같은 레이아웃을 사용하는 페이지 간 이동에서는 레이아웃이 언마운트되지 않기 때문이다.
TypeScript 타입 정의
타입 안정성을 위해 페이지 타입을 확장할 수 있다.
// types/page.ts
import type { NextPage } from 'next';
import type { ReactElement, ReactNode } from 'react';
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
// pages/_app.tsx
import type { AppProps } from 'next/app';
import type { NextPageWithLayout } from '@/types/page';
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
export default function App({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout ?? ((page) => page);
return getLayout(<Component {...pageProps} />);
}
// pages/dashboard.tsx
import type { NextPageWithLayout } from '@/types/page';
import DashboardLayout from '@/components/DashboardLayout';
const Dashboard: NextPageWithLayout = () => {
return <div>대시보드</div>;
};
Dashboard.getLayout = function getLayout(page) {
return <DashboardLayout>{page}</DashboardLayout>;
};
export default Dashboard;
타입을 정의하면 getLayout 메서드의 시그니처가 자동완성되고 타입 체크가 된다.
getLayout 패턴의 동작 원리
JavaScript 함수는 객체다
이 패턴을 이해하려면 JavaScript의 근본적인 특성을 알아야 한다. JavaScript에서 함수는 일급 객체(first-class object)이므로 프로퍼티를 자유롭게 추가할 수 있다.
// 일반적인 함수
function sayHello() {
console.log('Hello');
}
// 함수에 프로퍼티 추가 - 완전히 유효한 JavaScript
sayHello.author = 'John';
sayHello.version = '1.0';
console.log(sayHello.author); // 'John'
sayHello(); // 'Hello'
React 컴포넌트도 함수이므로 같은 원리가 적용된다. Dashboard.getLayout = ...는 단지 함수 객체에 프로퍼티를 할당하는 것이다.
_app.tsx에서 무슨 일이 일어나는가
_app.tsx의 동작을 단계별로 분해하면 다음과 같다.
export default function App({ Component, pageProps }: AppPropsWithLayout) {
// 1. Component는 현재 페이지 컴포넌트 (예: Dashboard 함수)
// 2. Component.getLayout으로 해당 함수의 getLayout 프로퍼티에 접근
// 3. 없으면 (page) => page 사용 (아무것도 감싸지 않음)
const getLayout = Component.getLayout ?? ((page) => page);
// 4. <Component {...pageProps} />로 페이지 렌더링
// 5. 렌더링된 결과를 getLayout에 전달
// 6. getLayout이 레이아웃으로 감싼 결과를 반환
return getLayout(<Component {...pageProps} />);
}
핵심은 <Component {...pageProps} />가 먼저 평가되어 React Element가 되고, 이것이 getLayout 함수의 인자로 전달된다는 점이다.
왜 이 패턴이 상태를 유지하는가
일반적인 조건부 렌더링과 비교해보자.
// 방식 A: 조건부 렌더링 (상태 유지 X)
function App({ Component, pageProps }) {
if (Component.needsDashboard) {
return (
<DashboardLayout>
<Component {...pageProps} />
</DashboardLayout>
);
}
return <Component {...pageProps} />;
}
위 방식에서 /dashboard에서 /dashboard/settings로 이동하면:
- 조건문이 다시 평가됨
- JSX가 새로 생성됨
- React는 이를 새로운 트리로 인식
DashboardLayout이 언마운트되고 다시 마운트됨- 상태 손실
// 방식 B: getLayout 패턴 (상태 유지 O)
function App({ Component, pageProps }) {
const getLayout = Component.getLayout ?? ((page) => page);
return getLayout(<Component {...pageProps} />);
}
getLayout 패턴에서 /dashboard에서 /dashboard/settings로 이동하면:
- 두 페이지 모두 같은
getLayout함수 참조를 가짐 - 동일한 레이아웃 구조가 반환됨
- React reconciliation이
DashboardLayout을 동일한 컴포넌트로 인식 - 레이아웃은 유지되고 내부
children만 교체됨 - 상태 유지
React는 컴포넌트 타입과 트리 구조가 같으면 기존 인스턴스를 재사용한다. getLayout 패턴은 이 원리를 활용해 레이아웃 상태를 보존한다.
주의사항
getLayout은 Next.js 기능이 아니다
getLayout은 Next.js가 제공하는 API가 아니라 개발자가 만드는 패턴이다. JavaScript에서 함수도 객체이므로 함수 컴포넌트에 프로퍼티를 추가할 수 있다.
이름도 getLayout 대신 withLayout, customLayout 등 원하는 대로 정할 수 있다. 다만 Next.js 공식 문서에서 getLayout이라는 이름으로 소개했기 때문에 이것이 관례가 되었다.
_app.tsx 레이아웃과 함께 사용
Per-page Layouts와 _app.tsx의 레이아웃을 함께 사용할 수도 있다.
// pages/_app.tsx
export default function App({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout ?? ((page) => page);
return (
<GlobalLayout>
{getLayout(<Component {...pageProps} />)}
</GlobalLayout>
);
}
위 코드는 모든 페이지에 GlobalLayout을 적용하고, 추가로 각 페이지의 getLayout도 적용한다.
getStaticProps, getServerSideProps와 함께 사용
Per-page Layouts는 데이터 페칭 메서드와 함께 사용할 수 있다.
// pages/posts/[id].tsx
import type { NextPageWithLayout } from '@/types/page';
import { GetStaticProps, GetStaticPaths } from 'next';
import BlogLayout from '@/components/BlogLayout';
type Props = {
post: {
id: string;
title: string;
content: string;
};
};
const Post: NextPageWithLayout<Props> = ({ post }) => {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
};
Post.getLayout = function getLayout(page) {
return <BlogLayout>{page}</BlogLayout>;
};
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
// 데이터 페칭 로직
const post = await fetchPost(params?.id as string);
return { props: { post } };
};
export const getStaticPaths: GetStaticPaths = async () => {
// 경로 생성 로직
return { paths: [], fallback: 'blocking' };
};
export default Post;
데이터 페칭과 레이아웃 설정을 모두 같은 페이지 파일에서 처리할 수 있다.