Bỏ qua, đến nội dung chính
Architecture12 min read28/9/2026

Cấu trúc thư mục dự án Front-End với Next.js App Router: Chuẩn Enterprise & Dễ scale

Khám phá kiến trúc thư mục chuẩn Feature-Based kết hợp cùng Next.js App Router giúp phân tách rõ ràng trách nhiệm, giải quyết triệt để vấn đề phình to mã nguồn, tối ưu hóa ranh giới Server/Client Components và nâng cao tốc độ phát triển cho team từ nhỏ đến lớn.

N

Nguyễn Duy Noa

Frontend Developer

Khi bắt đầu một dự án Next.js (đặc biệt với App Router), hầu hết lập trình viên thường bắt đầu bằng cách ném tất cả component vào thư mục components/, logic gọi API vào utils/ hay services/, và các trang vào app/.

Cách làm này rất nhanh ở giai đoạn đầu (MVP), nhưng khi ứng dụng mở rộng lên hàng chục tính năng, hàng trăm components và có nhiều kỹ sư cùng tham gia phát triển:

  • Khó định vị mã nguồn: Sửa một màn hình Dashboard nhưng phải nhảy qua lại giữa 6 thư mục phân tán khắp codebase.
  • Rò rỉ phụ thuộc (Coupling): Xóa một tính năng cũ trở thành cơn ác mộng vì không biết component nào đang được dùng chung, component nào thuộc riêng tính năng đó.
  • Nhầm lẫn ranh giới Server & Client Components: Lạm dụng directive "use client" ở cấp cao khiến toàn bộ cây component con mất đi lợi thế Server Rendering.

Bài viết này sẽ hướng dẫn bạn thiết lập một kiến trúc thư mục Feature-Based kết hợp App Router chuẩn Enterprise, đảm bảo tính mô-đun hóa cao, dễ bảo trì và mở rộng bền vững.

1. Triết lý cốt lõi: Feature-Based Architecture & Colocation

Trong kiến trúc truyền thống theo kỹ thuật (Layered Architecture), mã nguồn được chia theo loại file: tất cả component nằm ở components/, tất cả hooks ở hooks/, tất cả types ở types/.

Ngược lại, Feature-Based Architecture tổ chức mã nguồn theo nghiệp vụ (Domain/Feature). Mọi thứ liên quan đến một tính năng (giao diện, hooks, API calls, types, utils) đều được gom về cùng một nơi (Colocation).

Ưu điểm nổi bật:

  • Độc lập và tự đóng gói (Self-contained): Một tính năng có thể được phát triển, kiểm thử, refactor hoặc xóa bỏ mà không làm ảnh hưởng tới các phần còn lại.
  • Dễ onboarding: Kỹ sư mới nhận task thuộc module nào chỉ cần mở đúng thư mục của module đó để làm việc.
  • Tách biệt rõ ràng giữa Mã dùng chung (Shared) và Mã cục bộ (Local).

2. Tổng quan cây thư mục dự án Next.js chuẩn mực

Dưới đây là cấu trúc toàn diện của một dự án Next.js hiện đại, kết hợp giữa thư mục app/ (định tuyến) và src/ (nguồn lõi):

project-structure
bash
my-next-app/
├── app/                           # 🌐 TẦNG ĐỊNH TUYẾN MỎNG (Thin Routing Layer)
│   ├── [locale]/                  # Hỗ trợ i18n đa ngôn ngữ (nếu có)
│   │   ├── layout.tsx             # Root layout: Fonts, Providers, Metadata
│   │   ├── page.tsx               # Trang chủ: Re-export từ @/src/features/home
│   │   ├── blog/
│   │   │   ├── page.tsx           # Re-export @/src/features/blog
│   │   │   └── [slug]/page.tsx    # Re-export @/src/features/blog/components/Detail
│   │   ├── dashboard/
│   │   │   └── page.tsx           # Re-export @/src/features/dashboard
│   │   └── not-found.tsx          # Trang 404
│   ├── api/                       # Next.js Route Handlers (REST / Webhooks)
│   │   ├── contact/route.ts
│   │   └── webhooks/route.ts
│   ├── robots.ts                  # SEO robots
│   ├── sitemap.ts                 # Dynamic Sitemap
│   └── globals.css                # Global CSS & Tailwind config
│
├── src/                           # 🧠 TẦNG NGHIỆP VỤ & NGUYÊN BẢN (Business Logic)
│   ├── features/                  # Module nghiệp vụ chia theo tính năng
│   │   ├── auth/                  # Module Authentication
│   │   ├── blog/                  # Module Blog
│   │   │   ├── components/        # Components nội bộ của riêng Blog
│   │   │   │   ├── BlogCard.tsx
│   │   │   │   └── BlogToc.tsx
│   │   │   ├── hooks/             # Hooks nội bộ của Blog
│   │   │   ├── services/          # API queries/mutations của Blog
│   │   │   ├── types/             # Kiểu dữ liệu Blog
│   │   │   └── index.tsx          # Public view / Main feature entry
│   │   └── dashboard/             # Module Dashboard
│   │
│   ├── components/                # UI dùng chung toàn ứng dụng (Shared UI)
│   │   ├── ui/                    # Base primitives (Button, Modal, Input - shadcn)
│   │   ├── layout/                # Header, Footer, Sidebar, Navigation
│   │   ├── feedback/              # Toast, Skeleton, ErrorBoundary, EmptyState
│   │   └── providers/             # React Context & QueryClient Providers
│   │
│   ├── services/                  # Network Layer tập trung
│   │   ├── apiClient.ts           # Axios / Fetch client factory & Interceptors
│   │   └── index.ts               # Barrel export các API client
│   │
│   ├── lib/                       # Cấu hình SDKs & Thư viện bên thứ 3
│   │   ├── supabase/              # Supabase Client & Server helpers
│   │   ├── mongodb/               # Database connection pool
│   │   └── utils.ts               # Helper cn() và các hàm tiện ích chung
│   │
│   ├── hooks/                     # Custom hooks dùng chung toàn dự án
│   │   ├── useMediaQuery.ts
│   │   └── useDebounce.ts
│   │
│   ├── types/                     # TypeScript types toàn cục (Global interfaces)
│   │   └── api.ts
│   │
│   └── constants/                 # Hằng số, config, navigation links
│       └── routes.ts
│
├── public/                        # Static assets (images, fonts, favicons, pdfs)
├── messages/                      # i18n translations (vi.json, en.json)
├── tsconfig.json                  # Path aliases (@/*, @/features/*)
└── next.config.ts                 # Cấu hình Next.js (images, security headers)

3. Nguyên tắc 'Thin Routing': Giữ thư mục app/ mỏng nhất có thể

Một sai lầm rất phổ biến là viết toàn bộ JSX, state và API calls trực tiếp bên trong các file page.tsx của thư mục app/. Điều này khiến thư mục app/ trở nên cồng kềnh, khó di chuyển và khó viết Unit Test.

Giải pháp: Coi thư mục app/ thuần túy là bộ phận điều hướng (Routing Dispatcher). File page.tsx chỉ làm các nhiệm vụ sau:

  1. Nhận params / searchParams từ URL.
  2. Khai báo Metadata (generateMetadata) hoặc Structured Data (JSON-LD).
  3. Import và render View Component chính từ thư mục src/features/.
app/[locale]/blog/[slug]/page.tsx
typescript
import { getPostBySlug } from "@/src/features/blog/services/postService";
import { BlogPostDetail } from "@/src/features/blog";
import { notFound } from "next/navigation";
import type { Metadata } from "next";

interface PageProps {
  params: Promise<{ slug: string; locale: string }>;
}

// 1. Tầng định tuyến khai báo SEO Metadata
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) return {};

  return {
    title: `${post.title} | Duy Noa`,
    description: post.excerpt,
  };
}

// 2. Page component chỉ đóng vai trò Controller mỏng
export default async function BlogPostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) {
    notFound();
  }

  // Chuyển toàn bộ việc render UI cho feature component
  return <BlogPostDetail post={post} />;
}

Bạn đang gặp vấn đề tương tự?

Tôi có thể giúp bạn tối ưu website — liên hệ để trao đổi miễn phí.

4. Giải phẫu cấu trúc nội bộ của một Feature

Mỗi thư mục con bên trong src/features/[feature-name]/ đại diện cho một ranh giới nghiệp vụ riêng biệt. Một feature hoàn chỉnh có cấu trúc như sau:

src/features/blog/
bash
src/features/blog/
├── components/                    # Components nội bộ của riêng Blog
│   ├── BlogCard.tsx               # Card bài viết
│   ├── BlogPostDetail.tsx         # Giao diện chi tiết
│   ├── BlogSearchFilter.tsx       # Thanh tìm kiếm & lọc category
│   └── BlogTableOfContents.tsx    # Mục lục bài viết
├── hooks/                         # Hooks nghiệp vụ nội bộ (useReadingProgress.ts)
├── types/                         # TypeScript interfaces (BlogPost, BlogCategory)
└── index.tsx                      # Main Entry Point export ra ngoài

5. Tầng Service & TanStack Query: Quản lý Queries và Mutations chuẩn CQRS

Khi ứng dụng sử dụng TanStack Query (React Query), đừng viết gọi API lẫn lộn trong component. Hãy tổ chức tầng src/services/ theo nguyên tắc phân tách trách nhiệm (CQRS):

  1. [domain]Api.ts: Chứa các hàm gọi Axios / Fetch thuần túy (không chứa React hooks, dùng được ở cả Server và Client).
  2. use[Domain]Queries.ts: Gom toàn bộ thao tác ĐỌC (useQuery) cho module đó.
  3. use[Domain]Mutations.ts: Gom toàn bộ thao tác GHI / SỬA / XÓA (useMutation) kèm logic tự động làm mới cache (invalidateQueries).
src/services/admin/blog/useAdminBlogMutations.ts
typescript
// src/services/admin/blog/useAdminBlogMutations.ts
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { adminBlogApi } from "./adminBlogApi";
import type { CreatePostInput } from "./types";

export function useAdminBlogMutations() {
  const queryClient = useQueryClient();

  const invalidateBlogCache = () => {
    queryClient.invalidateQueries({ queryKey: ["adminPosts"] });
    queryClient.invalidateQueries({ queryKey: ["blogPosts"] });
  };

  const createPost = useMutation({
    mutationFn: (data: CreatePostInput) => adminBlogApi.createPost(data),
    onSuccess: invalidateBlogCache,
  });

  const deletePost = useMutation({
    mutationFn: (id: string) => adminBlogApi.deletePost(id),
    onSuccess: invalidateBlogCache,
  });

  return { createPost, deletePost };
}

6. Chiến lược chia sẻ Hooks giữa Public Portal & Admin Dashboard

Trong dự án chứa cả trang khách hàng (Public Client) và trang quản trị (Admin), việc chia sẻ Hook được phân theo 3 cấp độ:

  • 1. Hook tiện ích UI / Browser chung (src/hooks/):

Hoàn toàn không dính nghiệp vụ (vd: useDebounce, usePagination, useMediaQuery, useFileUpload). Cả thanh search ở Client và thanh filter ở Admin đều dùng chung.

  • 2. Hook lấy dữ liệu dùng chung (src/services/shared/):

Các query lấy danh mục, xem bài viết, cấu hình hệ thống mà cả 2 bên đều cần đọc.

  • 3. Hook đặc thù theo vai trò (src/services/admin/ vs src/services/client/):

Phía Client cần cache lâu (staleTime: 5 phút), phía Admin cần realtime (staleTime: 0), token Bearer Auth và các quyền can thiệp dữ liệu.

7. Phân định rõ ràng ranh giới Server vs Client Components

Trong Next.js App Router, mặc định mọi component đều là React Server Components (RSC) chạy trên máy chủ. Chúng ta chỉ gắn "use client" khi thực sự cần:

  • Sử dụng React Hooks (useState, useEffect, useMemo, v.v.).
  • Lắng nghe sự kiện người dùng (onClick, onChange, onSubmit).
  • Sử dụng Browser API (window, localStorage, navigator).

Quy tắc đẩy Client Component xuống lá cây (Push Client Boundary Down):
Đừng đánh dấu "use client" ở toàn bộ màn hình hay Component cha lớn. Hãy giữ Component cha là Server Component (để fetch data song song không gián đoạn) và chỉ bọc "use client" ở các tương tác nhỏ như nút Like, thanh tìm kiếm, Modal, hoặc Animation.

src/features/blog/components/BlogSearchFilter.tsx
typescript
// ✅ ĐÚNG: Chỉ cô lập tương tác người dùng vào component con
"use client";

import { useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";

export function BlogSearchFilter() {
  const [term, setTerm] = useState("");
  const [isPending, startTransition] = useTransition();
  const router = useRouter();
  const pathname = usePathname();

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault();
    startTransition(() => {
      router.push(`${pathname}?q=${encodeURIComponent(term)}`);
    });
  };

  return (
    <form onSubmit={handleSearch} className="relative flex items-center">
      <input
        type="search"
        value={term}
        onChange={(e) => setTerm(e.target.value)}
        placeholder="Tìm kiếm bài viết..."
        className="w-full rounded-xl bg-zinc-900 px-4 py-2 text-zinc-100 placeholder-zinc-500 border border-zinc-800 focus:outline-none focus:border-blue-500"
      />
      {isPending && <span className="text-xs text-zinc-400 ml-2">Đang lọc...</span>}
    </form>
  );
}

8. Những sai lầm kinh điển cần tránh

Khi thiết kế cấu trúc thư mục, đây là 4 cạm bẫy mà các đội ngũ kỹ thuật thường xuyên mắc phải:

  1. Thư mục shared/ hoặc common/ biến thành "bãi rác":

Mọi người tiện tay ném mọi thứ không biết để đâu vào shared/. Sau vài tháng, thư mục này chứa hàng trăm file không rõ nguồn gốc.
*Giải pháp*: Chỉ đưa vào shared khi component/hook đó được tái sử dụng ở ít nhất 2 feature độc lập.

  1. Lồng thư mục quá sâu (Over-nesting):

Tạo cấu trúc quá sâu dạng src/features/a/components/sub-a/item/header/title/index.tsx. Càng sâu càng khó điều hướng. Hãy duy trì độ sâu tối đa từ 3 đến 4 cấp.

  1. Circular Dependencies (Phụ thuộc vòng lặp):

Feature A import Feature B và Feature B lại import ngược lại Feature A.
*Giải pháp*: Nếu hai feature cần chia sẻ logic với nhau, hãy rút phần logic/type chung đó ra tầng src/services/, src/types/ hoặc src/components/ dùng chung.

  1. Trực tiếp truy cập dữ liệu Database từ Client Component:

Tuyệt đối không import Database clients (Prisma, Mongoose, Supabase Admin Server) vào các file Client Component ("use client"), gây lộ thông tin bí mật và lỗi build.

Kết luận

Một cấu trúc thư mục tốt không phải là cấu trúc phức tạp nhất hay nhiều thư mục nhất, mà là cấu trúc giúp cả team code nhanh hơn, tự tin refactor và không sợ làm vỡ tính năng của nhau.

Bằng cách áp dụng mô hình Feature-Based kết hợp với sự tinh gọn của App Router, dự án Next.js của bạn sẽ luôn sẵn sàng mở rộng từ một landing page cá nhân cho tới một ứng dụng SaaS quy mô lớn phục vụ hàng triệu người dùng.

Next.jsApp RouterFolder StructureArchitectureReactTypeScriptClean CodeFrontend

Muốn thảo luận thêm?

Nếu bạn có câu hỏi cụ thể về web performance, React, hoặc muốn hợp tác dự án — liên hệ trực tiếp với tôi.

Gửi email
Cấu trúc thư mục dự án Front-End với Next.js App Router chuẩn Enterprise | Duy Noa