junyeokk
Blog
Next.js·2025. 10. 08

Per-page Layouts

Next.js에서 페이지마다 다른 레이아웃을 적용하는 패턴이다. _app.tsx에서 모든 페이지에 동일한 레이아웃을 적용하는 대신, 각 페이지가 자신만의 레이아웃을 정의할 수 있다.

왜 필요한가?

일반적으로 _app.tsx에서 레이아웃을 적용하면 모든 페이지가 동일한 레이아웃을 사용한다.

typescript
// pages/_app.tsx
export default function App({ Component, pageProps }: AppProps) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  );
}

하지만 실제 애플리케이션에서는 페이지마다 다른 레이아웃이 필요한 경우가 많다.

  • 홈페이지는 헤더만 있고 사이드바가 없다
  • 대시보드는 사이드바가 있다
  • 관리자 페이지는 다른 네비게이션을 사용한다
  • 로그인 페이지는 레이아웃이 아예 없다

Per-page Layouts 패턴을 사용하면 각 페이지가 필요한 레이아웃을 자유롭게 선택할 수 있다.

기본 구조

페이지 컴포넌트에 getLayout 메서드를 추가하고, _app.tsx에서 이를 호출하는 방식이다.

1. _app.tsx 설정

typescript
// 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. 레이아웃이 필요한 페이지

typescript
// 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. 레이아웃이 없는 페이지

typescript
// pages/login.tsx
export default function Login() {
  return (
    <div>
      <h1>로그인</h1>
      <form>{/* 로그인 폼 */}</form>
    </div>
  );
}

// getLayout을 정의하지 않으면 레이아웃 없이 페이지만 렌더링된다

getLayout을 정의하지 않으면 _app.tsx에서 (page) => page가 사용되어 페이지가 그대로 렌더링된다.

레이아웃 컴포넌트 작성

레이아웃 컴포넌트는 일반적인 React 컴포넌트다.

typescript
// 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으로 페이지 컴포넌트를 받아서 원하는 위치에 렌더링한다.

중첩 레이아웃

레이아웃 안에 또 다른 레이아웃을 중첩할 수 있다.

typescript
// 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의 중요한 장점은 페이지 전환 시 레이아웃의 상태가 유지된다는 점이다.

typescript
// 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 타입 정의

타입 안정성을 위해 페이지 타입을 확장할 수 있다.

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;
};
typescript
// 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} />);
}
typescript
// 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)이므로 프로퍼티를 자유롭게 추가할 수 있다.

typescript
// 일반적인 함수
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의 동작을 단계별로 분해하면 다음과 같다.

typescript
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 함수의 인자로 전달된다는 점이다.

왜 이 패턴이 상태를 유지하는가

일반적인 조건부 렌더링과 비교해보자.

typescript
// 방식 A: 조건부 렌더링 (상태 유지 X)
function App({ Component, pageProps }) {
  if (Component.needsDashboard) {
    return (
      <DashboardLayout>
        <Component {...pageProps} />
      </DashboardLayout>
    );
  }
  return <Component {...pageProps} />;
}

위 방식에서 /dashboard에서 /dashboard/settings로 이동하면:

  1. 조건문이 다시 평가됨
  2. JSX가 새로 생성됨
  3. React는 이를 새로운 트리로 인식
  4. DashboardLayout이 언마운트되고 다시 마운트됨
  5. 상태 손실
typescript
// 방식 B: getLayout 패턴 (상태 유지 O)
function App({ Component, pageProps }) {
  const getLayout = Component.getLayout ?? ((page) => page);
  return getLayout(<Component {...pageProps} />);
}

getLayout 패턴에서 /dashboard에서 /dashboard/settings로 이동하면:

  1. 두 페이지 모두 같은 getLayout 함수 참조를 가짐
  2. 동일한 레이아웃 구조가 반환됨
  3. React reconciliation이 DashboardLayout을 동일한 컴포넌트로 인식
  4. 레이아웃은 유지되고 내부 children만 교체됨
  5. 상태 유지

React는 컴포넌트 타입과 트리 구조가 같으면 기존 인스턴스를 재사용한다. getLayout 패턴은 이 원리를 활용해 레이아웃 상태를 보존한다.

주의사항

getLayout은 Next.js 기능이 아니다

getLayout은 Next.js가 제공하는 API가 아니라 개발자가 만드는 패턴이다. JavaScript에서 함수도 객체이므로 함수 컴포넌트에 프로퍼티를 추가할 수 있다.

이름도 getLayout 대신 withLayout, customLayout 등 원하는 대로 정할 수 있다. 다만 Next.js 공식 문서에서 getLayout이라는 이름으로 소개했기 때문에 이것이 관례가 되었다.

_app.tsx 레이아웃과 함께 사용

Per-page Layouts와 _app.tsx의 레이아웃을 함께 사용할 수도 있다.

typescript
// 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는 데이터 페칭 메서드와 함께 사용할 수 있다.

typescript
// 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;

데이터 페칭과 레이아웃 설정을 모두 같은 페이지 파일에서 처리할 수 있다.

참고 자료