Next.js Intercepting Routes 모달 라우팅

··설명 약 7분·작성: 해비·블로그 소개

Next.js에서 같은 상세 URL을 목록에서는 모달로, 직접 접근에서는 페이지로 어떻게 보여주나요?

같은 상세 URL도 목록에서 클릭했을 때는 모달, 주소로 직접 열었을 때는 전체 페이지로 보여줄 수 있습니다. Parallel slot 안의 Intercepting Route는 클라이언트 이동을 가로채고 일반 상세 route는 직접 접근을 처리합니다.

적용 조건·범위: (.)와 (..)는 URL route segment를 기준으로 셉니다. @modal slot과 route group은 이 깊이에 포함하지 않습니다. 닫기의 router.back()은 이전 히스토리로 돌아가는 동작이므로 목록에서 연 모달에 사용하고, 직접 열린 전체 상세에는 명시적인 목록 Link를 둡니다.

본문 근거: Next.js Intercepting Route 경로 규칙

독립 실습: URL 직접 접근과 Link 모달 나누기

import type { ReactNode } from 'react';
export default function Layout({ children, modal }: { children: ReactNode; modal: ReactNode }) {
  return <>{children}{modal}</>;
}
import Link from 'next/link';
export default function Page() {
  return <main><h1>목록</h1><Link href="/practice/items/one">one 열기</Link></main>;
}
import Link from 'next/link';
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return <main><h1>전체 상세 {id}</h1><Link href="/practice">목록</Link></main>;
}
export default function Default() { return null; }
export default function Empty() { return null; }
import Close from './close';
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return <Close><h2>가로챈 상세 {id}</h2></Close>;
}

기대 결과: /practice의 one 열기 Link를 누르면 URL이 /practice/items/one으로 바뀌고 목록을 배경으로 가로챈 상세 one dialog가 표시되는 것이 기대 결과입니다. 같은 주소를 직접 열면 전체 상세 one과 목록 Link가 표시됩니다. 닫기·Escape는 목록에서 연 경우의 이전 주소로 돌아가는지 확인합니다.

직접 실행: 환경 준비와 전체 파일

React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.

npm run dev 후 /practice에서 Link를 눌러 상세로 이동하고 닫기·Escape를 비교합니다. 같은 상세 주소를 주소창에 직접 입력하거나 새로고침합니다.

app/practice/layout.tsx 전체 코드

app/practice/page.tsx 전체 코드

app/practice/items/[id]/page.tsx 전체 코드

app/practice/@modal/default.tsx 전체 코드

app/practice/@modal/[…rest]/page.tsx 전체 코드

app/practice/@modal/(.)items/[id]/page.tsx 전체 코드

app/practice/@modal/(.)items/[id]/close.tsx 전체 코드

"use client";
import { useEffect, useRef, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
export default function Close({ children }: { children: ReactNode }) {
  const router = useRouter();
  const ref = useRef<HTMLDialogElement>(null);
  useEffect(() => { ref.current?.showModal(); }, []);
  return <dialog ref={ref} aria-label="상세" onCancel={event => { event.preventDefault(); router.back(); }}>
    {children}<button onClick={() => router.back()}>닫기</button>
  </dialog>;
}

자가 확인: slot과 (.)의 기준이 URL segment인지 실제 폴더 깊이인지 설명하고 직접 접근에서 목록 링크가 필요한 이유를 적습니다.

동작 흐름

/tasks
→ 목록

/tasks/task-1 직접 접근
→ 전체 상세 페이지

/tasks에서 링크 클릭
→ URL은 /tasks/task-1
→ 화면은 목록 + 상세 modal

폴더 구조

app/(app)/
├─ tasks/
│  └─ [id]/
│     └─ page.tsx
└─ @modal/
   └─ (.)tasks/
      └─ [id]/
         └─ page.tsx

전체 상세 페이지

export default async function TaskDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  return (
    <main>
      <h1>작업 상세</h1>
      <p>작업 ID: {id}</p>
    </main>
  );
}

modal 닫기

Client Component로 닫기 버튼을 분리합니다.

'use client';

import { useRouter } from 'next/navigation';

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

  return (
    <button
      type="button"
      onClick={() => router.back()}
    >
      닫기
    </button>
  );
}

자주 발생하는 오류

  • intercepting route 위치 계산 오류
  • Parallel slot 없이 intercept만 사용
  • 새로고침에서도 배경 목록이 유지될 것으로 기대
  • 닫기 후 URL 복원 흐름 누락

(.)와 (..)는 URL route segment를 기준으로 셉니다. @modal slot과 route group은 이 깊이에 포함하지 않습니다. 닫기의 router.back()은 이전 히스토리로 돌아가는 동작이므로 목록에서 연 모달에 사용하고, 직접 열린 전체 상세에는 명시적인 목록 Link를 둡니다.

원리 확인: Next.js Intercepting Route 경로 규칙 (2026-10-12). 아래는 기대 결과이며 이 편집에서 실제 실행은 확인하지 않았습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.

전체 상세 페이지와 가로채는 상세 페이지가 같은 getTask를 사용합니다. 모달은 native dialog의 showModal로 열고 Escape·닫기 버튼은 router.back으로 돌아갑니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow Next.js 프로젝트 적용

시작 체크포인트: 이전: Next.js Parallel Routes default tsx의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: app/(app)/@modal/(.)tasks/[id]/page.tsx, app/(app)/tasks/[id]/page.tsx, app/(app)/tasks/page.tsx, app/ui/modal.tsx, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

직접 확인: 동일 URL의 두 화면

독자 질문: /tasks/task-1은 같은 주소인데 왜 목록 클릭과 직접 접근의 화면이 다를까요? @modal/(.)tasks/[id]/page.tsx는 클라이언트 이동을 가로채고 tasks/[id]/page.tsx는 직접 접근의 전체 상세를 처리합니다.

  1. /tasks에서 ‘App Router 공부’ 링크를 누릅니다. 예상 URL은 /tasks/task-1, 예상 화면은 배경 작업 목록과 제목·설명·상태가 담긴 상세 dialog입니다.
  2. 모달의 닫기 버튼 또는 Escape를 눌러 router.back()으로 돌아가는지 확인합니다. app/ui/modal.tsx는 showModal()과 onCancel을 사용합니다.
  3. /tasks/task-1을 주소창에 직접 입력하거나 새로고침합니다. 이때는 목록 배경 없는 전체 상세 페이지가 예상됩니다. 직접 접근은 돌아갈 목록 히스토리를 보장하지 않으므로 전체 상세의 ‘목록’ 링크를 사용합니다. 존재하지 않는 ID는 notFound() 경로를 점검합니다.
  4. npm run typecheck, npm run build, npm run start 결과를 따로 기록하고 개발·프로덕션에서 두 탐색 방식을 확인합니다.

오류 점검

링크 클릭에도 전체 상세가 나오면 @modal slot, (.)tasks 상대 위치, layout의 modal prop을 확인합니다. 직접 새로고침에서 모달이 사라지는 것은 예상 가능한 흐름입니다. 닫기 후 예상 밖 페이지로 이동한다면 이전 히스토리와 router.back() 조건을 점검합니다.

검증 상태: 완료 코드와 공개 글을 대조했습니다. 이번 편집에서는 개발 서버·브라우저·타입 검사·빌드·프로덕션 서버를 실행하지 않아 실제 동작은 미확인입니다.

다음 학습

9회차에서는 [id], [slug], catch-all 동적 segment를 다룹니다.

완료 코드 ZIP 다운로드 — next-step-08-intercepting-routes

이전 체크포인트: next-step-07-parallel-routes

이번 회차의 변경

전체 상세 페이지와 가로채는 상세 페이지가 같은 getTask를 사용합니다. 모달은 native dialog의 showModal로 열고 Escape·닫기 버튼은 router.back으로 돌아갑니다. 직접 URL 접근은 전체 상세입니다.

변경·추가 파일:

  • app/(app)/tasks/page.tsx
  • app/ui/modal.tsx
  • app/(app)/tasks/[id]/page.tsx
  • app/(app)/@modal/(.)tasks/[id]/page.tsx
전체 파일·코드 보기

실행과 검증

Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.

npm install
npm run dev
npm run build
npm run start

전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.

완료 체크포인트: next-step-08-intercepting-routes

실습 프로젝트 전체 코드

.gitignore

node_modules
.next
.env*
!.env.example
*.tsbuildinfo

app/(app)/@modal/(.)tasks/[id]/page.tsx

import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import Modal from "@/app/ui/modal";
export default async function TaskModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  if (!task) notFound();
  return (
    <Modal>
      <h1>{task.title}</h1>
      <p>{task.description}</p>
      <p>상태: {task.completed ? "완료" : "진행 중"}</p>
    </Modal>
  );
}

app/(app)/@modal/[…catchAll]/page.tsx

export default function ResetModal() {
  return null;
}

app/(app)/@modal/default.tsx

export default function Default() {
  return null;
}

app/(app)/layout.tsx

import type { ReactNode } from "react";
import SiteHeader from "@/app/_components/SiteHeader";
export default async function AppLayout({
  children,
  modal,
}: {
  children: ReactNode;
  modal: ReactNode;
}) {
  return (
    <>
      <SiteHeader />
      {children}
      {modal}
    </>
  );
}

app/(app)/settings/page.tsx

export default function SettingsPage() {
  return (
    <main>
      <h1>설정</h1>
      <p>TaskFlow 설정 화면입니다.</p>
    </main>
  );
}

app/(app)/tasks/[id]/page.tsx

import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
export default async function TaskDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  if (!task) notFound();
  return (
    <main>
      <h1>{task.title}</h1>
      <p>{task.description}</p>
      <p>상태: {task.completed ? "완료" : "진행 중"}</p>
      <Link href="/tasks">목록</Link>
    </main>
  );
}

app/(app)/tasks/page.tsx

import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
export default async function TasksPage() {
  const tasks = await getTasks();
  return (
    <main>
      <h1>작업 목록</h1>
      <TaskSearch />
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <Link href={"/tasks/" + task.id}>{task.title}</Link>
            <span> · {task.completed ? "완료" : "진행 중"}</span>
          </li>
        ))}
      </ul>
    </main>
  );
}

app/(app)/template.tsx

import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
  return <>{children}</>;
}

app/(public)/page.tsx

import Link from "next/link";
export default function HomePage() {
  return (
    <main>
      <h1>TaskFlow Next.js</h1>
      <p>App Router 기반 작업 관리 서비스입니다.</p>
      <Link href="/tasks">작업 목록 보기</Link>
    </main>
  );
}

app/_components/SiteHeader.tsx

import Link from "next/link";
export default function SiteHeader() {
  return (
    <header>
      <nav aria-label="주 메뉴">
        <Link href="/">홈</Link>
        <Link href="/tasks">작업</Link>
        <Link href="/settings">설정</Link>
      </nav>
    </header>
  );
}

app/globals.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #171717;
  background: #fafafa;
  font-family: Arial, sans-serif;
  line-height: 1.6;
}
header,
main {
  width: min(880px, calc(100% - 32px));
  margin: 24px auto;
}
nav {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: center;
}
a {
  color: inherit;
  text-underline-offset: 3px;
}
button,
input,
select {
  min-height: 44px;
  padding: 8px 12px;
  font: inherit;
  border: 1px solid #aaa;
  background: #fff;
  color: #171717;
  max-width: 100%;
}
button {
  cursor: pointer;
}
button:disabled {
  opacity: 0.6;
  cursor: wait;
}
input {
  display: block;
  width: 100%;
}
label {
  display: block;
}
form,
section,
aside {
  margin: 20px 0;
}
ul {
  padding: 0;
  list-style: none;
}
li {
  padding: 12px;
  margin: 12px 0;
  border: 1px solid #ccc;
  overflow-wrap: anywhere;
}
.controls {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
.controls form {
  margin: 0;
}
.modal {
  width: min(640px, calc(100% - 32px));
  border: 1px solid #777;
  padding: 24px;
  background: #fff;
  color: #171717;
}
.modal::backdrop {
  background: rgb(0 0 0 / 55%);
}
:focus-visible {
  outline: 3px solid #555;
  outline-offset: 3px;
}
[data-theme="dark"] {
  background: #202020;
  color: #fafafa;
  padding: 16px;
}
[data-density="compact"] li {
  padding: 8px;
  margin: 8px 0;
}
img {
  max-width: 100%;
  height: auto;
}

app/layout.tsx

import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
  title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
  description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  );
}

app/lib/data.ts

import type { Task } from "./types";
const tasks: Task[] = [
  {
    id: "task-1",
    title: "App Router 공부",
    description: "서버와 브라우저의 역할을 구분합니다.",
    completed: false,
    ownerId: "demo",
  },
  {
    id: "task-2",
    title: "TaskFlow 만들기",
    description: "누적 실습을 완성합니다.",
    completed: true,
    ownerId: "demo",
  },
];
export async function getTasks() {
  return tasks;
}
export async function getTask(id: string) {
  return tasks.find((t) => t.id === id);
}
export async function getTaskStats() {
  const ts = await getTasks();
  return { total: ts.length, completed: ts.filter((t) => t.completed).length };
}

app/lib/types.ts

export type Task = {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  ownerId: string;
};

app/ui/modal.tsx

"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDialogElement>(null);
  const router = useRouter();
  useEffect(() => {
    const dialog = ref.current;
    if (dialog && !dialog.open) dialog.showModal();
    return () => {
      dialog?.close();
    };
  }, []);
  return (
    <dialog
      className="modal"
      ref={ref}
      aria-label="작업 상세"
      onCancel={(event) => {
        event.preventDefault();
        router.back();
      }}
    >
      <button type="button" onClick={() => router.back()}>
        닫기
      </button>
      {children}
    </dialog>
  );
}

app/ui/task-search.tsx

"use client";
import { useState } from "react";
export default function TaskSearch() {
  const [keyword, setKeyword] = useState("");
  return (
    <label>
      작업 검색
      <input
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
        placeholder="검색어 입력"
      />
    </label>
  );
}

next-env.d.ts

/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";


// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

next.config.ts

import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;

package.json

{
  "name": "taskflow-next",
  "version": "1.0.8",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "24.13.6",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": [
        "./*"
      ]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

글 소개·학습 준비·전체 목차

Next.js Intercepting Routes 모달 라우팅 학습 표지

Next.js · 8/38 · TaskFlow 누적 실습

Next.js Intercepting Routes 모달 라우팅

Next.js · 8 / 38 · 필수 학습 · Intercepting Routes

같은 상세 URL을 목록에서는 모달로, 직접 접근에서는 페이지로 보여 주려면? Parallel slot 안의 Intercepting Route가 클라이언트 이동을 가로채고 일반 상세 route는 직접 접근을 처리합니다. 두 방식의 탐색과 닫기·새로고침을 비교해 URL과 화면 배경의 차이를 확인합니다.

이번 글에서 배우는 내용

  • (.) 문법
  • 직접 접근과 클라이언트 이동 차이
  • Parallel Routes와 결합
  • modal 닫기
  • 동일 URL로 두 UX 제공

목차

다음 학습

다음 글에서는 동적 segment [id], [slug], catch-all 문법을 정리합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 39개 · 필수 38개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기