Page Router routing 설정하기

작성일: 2025. 10. 08최종 수정: 2025. 10. 09. 15시 01분

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 훅의 query 속성으로 접근할 수 있다.

주의: 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"]

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

라우팅 우선순위

여러 라우트 패턴이 동일한 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>
  );
}

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

참고 자료