Kể từ khi Next.js 13 giới thiệu App Router và chính thức trở thành mặc định từ Next.js 13.4 trở đi, cách tổ chức thư mục trong một dự án Next.js đã thay đổi đáng kể so với Pages Router truyền thống. Bài viết này sẽ đi sâu vào từng phần của cấu trúc thư mục, giải thích ý nghĩa, quy ước đặt tên, và những best practice khi tổ chức dự án thực tế.

1. Tổng Quan Cấu Trúc Thư Mục Gốc

Một dự án Next.js App Router điển hình có cấu trúc như sau:

my-nextjs-app/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── globals.css
│   ├── favicon.ico
│   ├── loading.tsx
│   ├── error.tsx
│   ├── not-found.tsx
│   ├── (marketing)/
│   │   ├── about/
│   │   │   └── page.tsx
│   │   └── pricing/
│   │       └── page.tsx
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   └── dashboard/
│   │       ├── page.tsx
│   │       └── settings/
│   │           └── page.tsx
│   ├── blog/
│   │   ├── page.tsx
│   │   └── [slug]/
│   │       └── page.tsx
│   └── api/
│       └── users/
│           └── route.ts
├── components/
├── lib/
├── hooks/
├── public/
├── next.config.js
├── package.json
├── tsconfig.json
└── tailwind.config.ts

Nhìn qua có vẻ phức tạp hơn Pages Router, nhưng thực chất mỗi thành phần đều có vai trò rất rõ ràng. Hãy phân tích từng phần.

2. Thư Mục app/ - Trái Tim Của Ứng Dụng

Thư mục app/ là nơi định nghĩa routing dựa trên cấu trúc folder (file-system based routing). Điểm khác biệt cốt lõi so với Pages Router là: mỗi route được đại diện bởi một folder, và bên trong folder đó chứa các file đặc biệt quy định hành vi của route.

2.1. Các File Đặc Biệt (Special Files)

Next.js quy định một tập hợp tên file cố định, mỗi file đảm nhận một chức năng riêng:

File Vai trò
layout.tsx Giao diện dùng chung, bọc quanh các page con, không bị re-render khi chuyển route
page.tsx Giao diện chính của route, bắt buộc phải có để route "public" (truy cập được từ URL)
loading.tsx UI hiển thị khi dữ liệu đang tải (tận dụng React Suspense)
error.tsx UI xử lý lỗi runtime trong route đó (phải là Client Component)
not-found.tsx UI hiển thị khi gọi hàm notFound() hoặc route không tồn tại
route.ts Định nghĩa API endpoint (Route Handler), thay thế cho pages/api
template.tsx Tương tự layout nhưng tạo instance mới mỗi lần điều hướng
default.tsx Fallback UI cho Parallel Routes khi không match slot nào

Điều quan trọng cần nhớ: chỉ file page.tsx mới khiến route trở nên truy cập được qua URL. Nếu một folder chỉ chứa layout.tsx mà không có page.tsx, route đó sẽ không có URL công khai - đây là lý do vì sao ta có thể tạo các folder tổ chức code mà không ảnh hưởng đến URL thực tế (sẽ nói ở phần Route Groups).

2.2. Nested Layouts - Cơ Chế Lồng Ghép Giao Diện

Một trong những cải tiến lớn nhất của App Router là khả năng lồng layout theo cây thư mục. Ví dụ:

app/
├── layout.tsx          → Layout gốc (bọc toàn bộ app)
└── dashboard/
    ├── layout.tsx      → Layout riêng cho /dashboard
    └── settings/
        ├── layout.tsx  → Layout riêng cho /dashboard/settings
        └── page.tsx

Khi người dùng truy cập /dashboard/settings, Next.js sẽ tự động lồng ba layout này vào nhau theo thứ tự: Root Layout → Dashboard Layout → Settings Layout → Page. Cơ chế này giúp tránh lặp code UI (header, sidebar, navigation) mà không cần import thủ công như trước đây.

Một lưu ý quan trọng: layout không re-render khi chuyển route con, giúp giữ nguyên state (ví dụ: vị trí scroll của sidebar, trạng thái form) - đây là lợi thế lớn so với việc mọi thứ đều re-render trong Pages Router.

3. Route Groups (tenNhom) - Tổ Chức Không Ảnh Hưởng URL

Dấu ngoặc đơn quanh tên folder, ví dụ (marketing) hoặc (dashboard), tạo ra một Route Group. Đây là quy ước cho phép:

  • Nhóm các route liên quan về mặt logic mà không thêm segment vào URL.
  • Áp dụng layout riêng cho từng nhóm route mà không ảnh hưởng cấu trúc URL.

Ví dụ:

app/
├── (marketing)/
│   ├── layout.tsx     → Layout cho trang marketing (header, footer công khai)
│   ├── about/page.tsx     → URL: /about
│   └── pricing/page.tsx   → URL: /pricing
└── (dashboard)/
    ├── layout.tsx     → Layout cho khu vực quản trị (sidebar, auth check)
    └── dashboard/page.tsx  → URL: /dashboard

Chú ý: URL cuối cùng không chứa (marketing) hay (dashboard) - đây chỉ là công cụ tổ chức code cho developer, hoàn toàn "vô hình" với người dùng cuối. Đây là giải pháp tuyệt vời khi một ứng dụng có nhiều "khu vực" khác nhau (trang công khai, khu vực đăng nhập, khu vực admin) cần layout và logic tách biệt.

4. Dynamic Routes - Route Động

Để tạo route với tham số động, ta dùng dấu ngoặc vuông:

app/
└── blog/
    └── [slug]/
        └── page.tsx    → Khớp với /blog/hello-world, /blog/nextjs-13, ...

Trong page.tsx, tham số slug được truyền vào qua props:

export default function BlogPost({ params }: { params: { slug: string } }) {
  return <h1>Bài viết: {params.slug}</h1>;
}

Ngoài dynamic segment cơ bản, còn có hai biến thể nâng cao:

  • Catch-all segments [...slug]: khớp với nhiều segment, ví dụ /blog/2024/07/helloslug = ['2024', '07', 'hello'].
  • Optional catch-all [[...slug]]: giống catch-all nhưng khớp cả khi không có segment nào (tức /blog cũng hợp lệ).

5. Private Folders _tenFolder - Loại Trừ Khỏi Routing

Dấu gạch dưới ở đầu tên folder, ví dụ _components hoặc _lib, báo cho Next.js biết folder này không phải là một route và sẽ bị loại hoàn toàn khỏi hệ thống routing, kể cả các file con bên trong.

app/
└── dashboard/
    ├── _components/
    │   └── Sidebar.tsx
    ├── _utils/
    │   └── formatDate.ts
    └── page.tsx

Đây là cách hữu ích để đặt các file hỗ trợ (component, helper function, type definition) ngay cạnh route sử dụng chúng mà không lo Next.js hiểu nhầm thành một route mới.

6. Parallel Routes @slot và Intercepting Routes

6.1. Parallel Routes

Ký hiệu @ tạo ra một "slot" - cho phép render đồng thời nhiều page độc lập trong cùng một layout, hữu ích cho dashboard phức tạp:

app/
├── layout.tsx
├── @team/
│   └── page.tsx
└── @analytics/
    └── page.tsx

Trong layout.tsx, các slot được nhận qua props và render song song:

export default function Layout({ children, team, analytics }: {
  children: React.ReactNode;
  team: React.ReactNode;
  analytics: React.ReactNode;
}) {
  return (
    <>
      {children}
      {team}
      {analytics}
    </>
  );
}

6.2. Intercepting Routes

Ký hiệu (.), (..), (..)(..), (...) cho phép "chặn" và hiển thị một route trong ngữ cảnh khác (ví dụ: mở ảnh dạng modal khi click từ feed, nhưng vẫn có URL riêng nếu load trực tiếp). Đây là kỹ thuật nâng cao thường thấy trong các ứng dụng kiểu Instagram-clone.

7. Thư Mục api/ và Route Handlers

Thay vì pages/api/*.ts như trước, App Router dùng file route.ts bên trong app/api/:

app/
└── api/
    └── users/
        └── route.ts
export async function GET(request: Request) {
  const users = await db.user.findMany();
  return Response.json(users);
}

export async function POST(request: Request) {
  const body = await request.json();
  const user = await db.user.create({ data: body });
  return Response.json(user, { status: 201 });
}

Mỗi HTTP method (GET, POST, PUT, DELETE, PATCH) được export như một hàm riêng trong cùng file, thay vì kiểm tra req.method như trước.

8. Các Thư Mục Ngoài app/ - Tổ Chức Code Dùng Chung

Ngoài app/, một dự án chuyên nghiệp thường có thêm:

  • components/: Chứa các React component tái sử dụng trên toàn app (Button, Modal, Card...). Nên tách thành components/ui/ cho component thuần UI và components/shared/ cho component nghiệp vụ.
  • lib/: Chứa logic nghiệp vụ, hàm tiện ích, cấu hình client (database client, API client, validation schema).
  • hooks/: Custom React hooks dùng chung.
  • types/: Định nghĩa TypeScript type/interface dùng chung toàn dự án.
  • public/: Static assets (ảnh, favicon, font) được serve trực tiếp qua URL gốc.

Một câu hỏi thường gặp: nên đặt components/ bên trong hay bên ngoài app/? Cả hai cách đều hợp lệ:

  • Đặt bên ngoài app/ (ngang hàng): phù hợp khi component dùng chung ở nhiều route khác nhau, dễ import với alias @/components/....
  • Đặt bên trong từng route (dùng private folder _components): phù hợp khi component chỉ phục vụ riêng route đó, giúp code "sống gần" nơi nó được dùng (co-location).

Nhiều team lớn áp dụng chiến lược kết hợp: component thực sự dùng chung (design system) đặt ở components/, còn component đặc thù cho một tính năng đặt ngay trong route đó dưới dạng _components.

9. So Sánh Nhanh: App Router vs Pages Router

Tiêu chí Pages Router App Router
Thư mục gốc pages/ app/
Định nghĩa route Tên file = route Tên folder = route, cần page.tsx
Layout dùng chung _app.tsx, _document.tsx layout.tsx lồng nhau theo cây
API routes pages/api/*.ts app/api/**/route.ts
Loading state Tự quản lý thủ công loading.tsx tự động qua Suspense
Error handling _error.tsx toàn cục error.tsx theo từng route
Server Components Không hỗ trợ mặc định Mặc định là Server Component

10. Một Số Lưu Ý Thực Tế Khi Tổ Chức Dự Án

  1. Co-location hợp lý: Đừng tách file quá sớm. Nếu một component chỉ dùng ở một route, hãy để nó gần route đó (_components) thay vì đẩy lên components/ chung ngay từ đầu.
  2. Route Groups cho phân quyền: Dùng route group để tách rõ khu vực public/private, ví dụ (auth) cho login/register, (protected) cho khu vực cần đăng nhập, mỗi nhóm có layout và middleware kiểm tra quyền riêng.
  3. Tránh lạm dụng Parallel/Intercepting Routes: Đây là công cụ mạnh nhưng phức tạp, chỉ nên dùng khi thực sự cần (modal ảnh, dashboard nhiều panel), không nên dùng cho mọi trường hợp.
  4. Server Component là mặc định: Trong app/, mọi component mặc định chạy trên server trừ khi khai báo "use client" ở đầu file. Hãy cân nhắc kỹ ranh giới Client/Server để tối ưu bundle size.
  5. Đặt tên file nhất quán: Luôn dùng đúng tên quy ước (page.tsx, layout.tsx...) vì Next.js nhận diện dựa trên tên file chính xác, sai chính tả sẽ khiến route không hoạt động mà không có cảnh báo rõ ràng.

Kết Luận

Cấu trúc thư mục của App Router tuy phức tạp hơn Pages Router ở lần đầu tiếp cận, nhưng thực chất là một hệ thống quy ước rất mạch lạc: file đặc biệt quy định hành vi, folder quy định route, và các ký hiệu đặc biệt ((), [], _, @, (.)) mở rộng khả năng tổ chức mà không phá vỡ cấu trúc URL. Hiểu rõ từng quy ước này không chỉ giúp tổ chức code sạch hơn mà còn tận dụng tối đa các tính năng như nested layout, streaming UI, và Server Components mà Next.js App Router mang lại.