페이지 라우팅 설정하기

작성일: 2025. 10. 04최종 수정: 2025. 10. 05. 06시 14분

Next.js의 Page Router는 pages 폴더 내의 파일 구조를 기반으로 자동으로 라우팅을 생성하는 시스템이다. 별도의 라우팅 설정 파일 없이 파일과 폴더만으로 URL 경로를 정의할 수 있다.

Page Router vs App Router
Next.js 13부터 App Router가 도입되었지만, Page Router는 여전히 안정적이고 널리 사용된다. App Router는 Server Components를 기본으로 하는 새로운 패러다임이며, Page Router는 기존의 Client-side Navigation을 중심으로 한다.

기본 라우팅

pages 폴더 내의 파일이 자동으로 라우트가 된다. 파일명이 곧 URL 경로다.

plain
pages/
├── index.tsx          → /
├── about.tsx          → /about
├── contact.tsx        → /contact
└── products.tsx       → /products

위 구조에서 index.tsx는 루트 경로 /에 매핑되고, about.tsx/about 경로에 매핑된다.

typescript
// pages/index.tsx
export default function Home() {
  return <h1>홈 페이지</h1>;
}
typescript
// pages/about.tsx
export default function About() {
  return <h1>소개 페이지</h1>;
}

각 파일은 기본적으로 React 컴포넌트를 default export 해야 한다. Next.js는 이 컴포넌트를 자동으로 페이지로 렌더링한다.

중첩 라우팅

폴더를 사용하면 중첩된 경로를 만들 수 있다.

plain
pages/
├── index.tsx              → /
├── blog/
│   ├── index.tsx          → /blog
│   ├── first-post.tsx     → /blog/first-post
│   └── second-post.tsx    → /blog/second-post
└── products/
    ├── index.tsx          → /products
    └── electronics.tsx    → /products/electronics

폴더 내의 index.tsx는 해당 폴더 경로의 기본 페이지가 된다. 예를 들어 blog/index.tsx/blog 경로에 매핑된다.

typescript
// pages/blog/index.tsx
export default function Blog() {
  return <h1>블로그 목록</h1>;
}
typescript
// pages/blog/first-post.tsx
export default function FirstPost() {
  return <h1>첫 번째 포스트</h1>;
}

이렇게 폴더 구조만으로 직관적인 URL 계층을 구성할 수 있다.

동적 라우팅 (URL Parameter)

동적 경로를 갖는 페이지를 생성하려면 파일명을 대괄호로 감싸면 된다. [id].tsx와 같이 작성하면 해당 부분이 동적 세그먼트가 된다.

plain
pages/
└── posts/
    ├── index.tsx          → /posts
    └── [id].tsx           → /posts/:id

[id].tsx 파일은 /posts/1, /posts/abc, /posts/hello 등 모든 경로에 매칭된다.

typescript
// pages/posts/[id].tsx
import { useRouter } from 'next/router';

export default function Post() {
  const router = useRouter();
  const { id } = router.query;

  return <h1>포스트 ID: {id}</h1>;
}

위 코드에서 useRouter 훅을 통해 URL의 동적 파라미터에 접근할 수 있다. router.query 객체에서 파일명에 정의한 id를 구조 분해 할당으로 가져온다.

사용자가 /posts/123에 접속하면 id"123"이 되고, /posts/hello에 접속하면 id"hello"가 된다.

여러 동적 세그먼트

여러 동적 세그먼트를 조합할 수도 있다.

plain
pages/
└── posts/
    └── [category]/
        └── [id].tsx       → /posts/:category/:id
typescript
// pages/posts/[category]/[id].tsx
import { useRouter } from 'next/router';

export default function Post() {
  const router = useRouter();
  const { category, id } = router.query;

  return (
    <div>
      <h1>카테고리: {category}</h1>
      <p>포스트 ID: {id}</p>
    </div>
  );
}

/posts/tech/123에 접속하면 category"tech", id"123"이 된다.

Query String

Query String은 페이지 경로에 영향을 주지 않으며, ? 뒤에 오는 파라미터들을 의미한다. 예를 들어 /search?q=nextjs&sort=latest에서 qsort가 Query String이다.

별도의 설정 없이 useRouter 훅으로 접근할 수 있다.

주의: next/router 사용useRouter는 next/router에서 import 해야 한다. next/navigation의 useRouter는 App Router 전용이므로 Page Router에서는 사용할 수 없다.

typescript
// pages/search.tsx
import { useRouter } from 'next/router';

export default function Search() {
  const router = useRouter();
  const { q, sort } = router.query;

  return (
    <div>
      <h1>검색어: {q}</h1>
      <p>정렬: {sort}</p>
    </div>
  );
}

사용자가 /search?q=nextjs&sort=latest에 접속하면 q"nextjs", sort"latest"가 된다.

Query String은 동적 라우팅 파라미터와 함께 사용할 수도 있다. 모든 파라미터는 router.query에 통합된다.

typescript
// pages/posts/[id].tsx
import { useRouter } from 'next/router';

export default function Post() {
  const router = useRouter();
  const { id, comments } = router.query;

  return (
    <div>
      <h1>포스트 ID: {id}</h1>
      {comments && <p>댓글 표시 모드</p>}
    </div>
  );
}

/posts/123?comments=true에 접속하면 id"123", comments"true"가 된다.

Catch-all Routes

단일 동적 세그먼트는 한 단계의 경로만 매칭한다. /posts/[id].tsx/posts/123은 매칭하지만 /posts/123/comments는 매칭하지 못한다.

여러 단계의 경로를 한 번에 처리하려면 Catch-all Routes를 사용한다. 대괄호 안에 ...을 붙이면 된다.

plain
pages/
└── docs/
    └── [...slug].tsx      → /docs/* (모든 하위 경로)

[...slug].tsx/docs/a, /docs/a/b, /docs/a/b/c 등 모든 하위 경로를 매칭한다.

typescript
// pages/docs/[...slug].tsx
import { useRouter } from 'next/router';

export default function Docs() {
  const router = useRouter();
  const { slug } = router.query;

  // slug는 배열로 전달됨
  return (
    <div>
      <h1>문서 경로</h1>
      <p>세그먼트: {Array.isArray(slug) ? slug.join(' / ') : slug}</p>
    </div>
  );
}

위 코드에서 slug는 경로 세그먼트를 배열로 담는다.

  • /docs/introduction 접속 시: slug = ["introduction"]
  • /docs/getting-started/installation 접속 시: slug = ["getting-started", "installation"]
  • /docs/api/hooks/useRouter 접속 시: slug = ["api", "hooks", "useRouter"]

배열의 각 요소가 경로의 각 세그먼트에 해당한다. 이를 활용해 동적으로 컨텐츠를 렌더링하거나 파일 시스템에서 마크다운 파일을 읽어올 수 있다.

Catch-all Routes의 한계

하지만 [...slug].tsx는 최소 하나의 세그먼트가 필요하다. /docs에는 매칭되지 않고 /docs/something부터 매칭된다.

/docs도 같은 컴포넌트로 처리하고 싶다면 두 가지 방법이 있다.

  1. docs/index.tsx를 별도로 만든다
  2. Optional Catch-all Routes를 사용한다

Optional Catch-all Routes

대괄호를 한 번 더 감싸면 Optional Catch-all Routes가 된다.

plain
pages/
└── docs/
    └── [[...slug]].tsx    → /docs 및 /docs/* (모든 하위 경로)

[[...slug]].tsx/docs도 매칭하고, /docs/a, /docs/a/b 등 모든 하위 경로도 매칭한다.

typescript
// pages/docs/[[...slug]].tsx
import { useRouter } from 'next/router';

export default function Docs() {
  const router = useRouter();
  const { slug } = router.query;

  if (!slug) {
    // /docs 경로
    return <h1>문서 홈</h1>;
  }

  // /docs/* 경로
  return (
    <div>
      <h1>문서 경로</h1>
      <p>세그먼트: {slug.join(' / ')}</p>
    </div>
  );
}
  • /docs 접속 시: slug = undefined
  • /docs/introduction 접속 시: slug = ["introduction"]
  • /docs/api/hooks 접속 시: slug = ["api", "hooks"]

이렇게 하면 하나의 컴포넌트로 모든 문서 경로를 처리할 수 있다.

Next.js에서 페이지 간 이동은 next/linkLink 컴포넌트를 사용한다. HTML의 <a> 태그와 달리 클라이언트 사이드 네비게이션을 제공한다.

typescript
import Link from 'next/link';

export default function Nav() {
  return (
    <nav>
      <Link href="/"></Link>
      <Link href="/about">소개</Link>
      <Link href="/blog">블로그</Link>
    </nav>
  );
}

Link 컴포넌트를 사용하면 페이지 전체를 새로고침하지 않고 필요한 부분만 업데이트한다. 이를 통해 빠른 페이지 전환과 상태 유지가 가능하다.

동적 경로는 문자열 보간이나 객체 형태로 전달할 수 있다.

typescript
import Link from 'next/link';

export default function PostList() {
  const posts = [
    { id: 1, title: '첫 번째 포스트' },
    { id: 2, title: '두 번째 포스트' },
  ];

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

객체 형태로도 전달할 수 있다.

typescript
<Link
  href={{
    pathname: '/posts/[id]',
    query: { id: post.id },
  }}
>
  {post.title}
</Link>

Prefetching

Link 컴포넌트는 기본적으로 viewport에 보이는 링크를 자동으로 prefetch한다. 사용자가 링크를 클릭하기 전에 미리 페이지를 로드해두기 때문에 즉각적인 페이지 전환이 가능하다.

프로덕션 빌드에서만 동작하며, prefetch={false}로 비활성화할 수 있다.

typescript
<Link href="/about" prefetch={false}>
  소개
</Link>

useRouter 훅

useRouter 훅은 현재 라우터 정보에 접근하고 프로그래매틱하게 네비게이션을 제어할 수 있게 한다.

typescript
import { useRouter } from 'next/router';

export default function Page() {
  const router = useRouter();

  console.log(router.pathname);  // 현재 경로 (예: /posts/[id])
  console.log(router.asPath);    // 브라우저에 표시되는 전체 경로 (예: /posts/123?sort=latest)
  console.log(router.query);     // 쿼리 파라미터 객체 (예: { id: '123', sort: 'latest' })

  return <div>페이지</div>;
}

주요 속성은 다음과 같다.

  • pathname: 현재 페이지의 경로 패턴 (동적 세그먼트는 대괄호 형태로 표시됨)
  • asPath: 실제 브라우저에 표시되는 전체 경로
  • query: URL 파라미터와 Query String을 담은 객체
  • route: pathname과 동일

프로그래매틱 네비게이션

Link 컴포넌트 외에 코드로 페이지를 이동할 수 있다. router.push()router.replace()를 사용한다.

router.push()

router.push()는 새로운 URL을 브라우저 히스토리에 추가하고 이동한다.

typescript
import { useRouter } from 'next/router';

export default function LoginPage() {
  const router = useRouter();

  const handleLogin = async () => {
    // 로그인 로직...

    // 로그인 성공 후 대시보드로 이동
    router.push('/dashboard');
  };

  return <button onClick={handleLogin}>로그인</button>;
}

Query String이나 동적 세그먼트도 전달할 수 있다.

typescript
// 문자열 형태
router.push('/posts/123?sort=latest');

// 객체 형태
router.push({
  pathname: '/posts/[id]',
  query: { id: '123', sort: 'latest' },
});

router.replace()

router.replace()는 현재 히스토리를 대체한다. 뒤로가기를 눌렀을 때 이전 페이지로 돌아가지 않는다.

typescript
const handleRedirect = () => {
  // 히스토리에 추가하지 않고 대체
  router.replace('/new-page');
};

로그인 후 리다이렉트처럼 사용자가 뒤로가기로 돌아가지 못하게 하고 싶을 때 유용하다.

기타 메서드

  • router.back(): 브라우저 히스토리에서 뒤로 이동
  • router.reload(): 현재 페이지 새로고침
  • router.prefetch(url): 특정 페이지를 미리 로드

라우팅 우선순위

여러 라우트 패턴이 동일한 URL에 매칭될 수 있는 경우, Next.js는 다음 우선순위로 라우트를 결정한다.

  1. 정적 라우트 (우선순위 가장 높음)
  2. 동적 라우트
  3. Catch-all 라우트 (우선순위 가장 낮음)
plain
pages/
├── posts/
│   ├── index.tsx          (1) /posts
│   ├── create.tsx         (2) /posts/create
│   ├── [id].tsx           (3) /posts/:id
│   └── [...slug].tsx      (4) /posts/*

위 구조에서:

  • /posts 접속 시: index.tsx 매칭
  • /posts/create 접속 시: create.tsx 매칭 (정적 라우트가 우선)
  • /posts/123 접속 시: [id].tsx 매칭
  • /posts/a/b/c 접속 시: [...slug].tsx 매칭

create.tsx가 있다면 /posts/create[id].tsx보다 우선한다. 정적 라우트가 동적 라우트보다 우선순위가 높기 때문이다.

404 페이지

존재하지 않는 경로에 접속할 때 표시되는 404 페이지를 커스터마이징할 수 있다. pages 폴더 최상위에 404.tsx 파일을 만들면 된다.

typescript
// pages/404.tsx
import Link from 'next/link';

export default function Custom404() {
  return (
    <div>
      <h1>404 - 페이지를 찾을 수 없습니다</h1>
      <p>요청하신 페이지가 존재하지 않습니다.</p>
      <Link href="/">홈으로 돌아가기</Link>
    </div>
  );
}

빌드 시 자동으로 정적 페이지로 생성되므로 추가 서버 부하 없이 빠르게 제공된다.

참고 자료