Tổ Chức Dự Án Next.js Ở Quy Mô Lớn: Những Bài Học Xương Máu
Những bài học đắt giá về cách tổ chức codebase Next.js sao cho không sụp đổ dưới sức nặng của chính nó. Kiến trúc theo feature, route group, ranh giới server/client, bẫy barrel file, và một cấu trúc thư mục thực tế bạn có thể áp dụng ngay.
Mọi dự án Next.js đều bắt đầu gọn gàng. Vài trang, một thư mục components, có thể thêm một file lib/utils.ts. Bạn cảm thấy năng suất. Bạn cảm thấy ngăn nắp.
Rồi sáu tháng trôi qua. Ba developer mới gia nhập. Thư mục components có 140 file. Ai đó tạo ra utils2.ts vì utils.ts đã 800 dòng. Không ai biết layout nào bọc route nào. Build mất bốn phút và bạn không biết tại sao.
Tôi đã trải qua chu kỳ này nhiều lần. Những gì tiếp theo là những gì tôi thực sự làm bây giờ, sau khi học được hầu hết những bài học này bằng cách ship sai thứ trước.
Cấu Trúc Mặc Định Sụp Đổ Rất Nhanh#
Tài liệu Next.js gợi ý một cấu trúc dự án. Nó ổn. Nó hoạt động cho blog cá nhân hay công cụ nhỏ. Nhưng ngay khi có hơn hai developer cùng chạm vào một codebase, cách tiếp cận "theo loại" bắt đầu nứt:
src/
components/
Button.tsx
Modal.tsx
UserCard.tsx
DashboardSidebar.tsx
InvoiceTable.tsx
InvoiceRow.tsx
InvoiceActions.tsx
...137 more files
hooks/
useAuth.ts
useInvoice.ts
useDebounce.ts
utils/
format.ts
validate.ts
misc.ts ← khởi đầu của kết thúc
app/
page.tsx
dashboard/
page.tsx
Vấn đề không phải là cách này "sai." Mà là nó scale tuyến tính theo feature nhưng không scale theo khả năng hiểu. Khi một developer mới cần làm việc với hóa đơn, họ phải mở đồng thời components/, hooks/, utils/, types/, và app/. Feature hóa đơn bị phân tán trên năm thư mục. Không có một nơi nào nói "đây là toàn bộ phần hóa đơn."
Tôi đã chứng kiến một team bốn developer dành cả sprint để refactor thành đúng cấu trúc này, tin rằng đó là cách "sạch." Trong vòng ba tháng họ lại bối rối, chỉ là với nhiều thư mục hơn.
Colocation Theo Feature Luôn Chiến Thắng#
Pattern thực sự sống sót qua thực tế với team thật là colocation theo feature. Ý tưởng rất đơn giản: mọi thứ liên quan đến một feature sống cùng nhau.
Không phải "tất cả component trong một thư mục và tất cả hook trong một thư mục khác." Ngược lại. Tất cả component, hook, type, và utility cho hóa đơn sống trong features/invoicing/. Mọi thứ cho auth sống trong features/auth/.
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
export function useInvoiceActions(invoice: Invoice) {
const markAsPaid = useCallback(async () => {
await invoiceApi.updateStatus(invoice.id, "paid");
}, [invoice.id]);
const duplicate = useCallback(async () => {
const newNumber = formatInvoiceNumber(Date.now());
await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
}, [invoice]);
return { markAsPaid, duplicate };
}Hãy chú ý các import. Tất cả đều relative, tất cả nằm trong feature. Hook này không vươn tới thư mục hooks/ toàn cục hay utils/format.ts dùng chung nào. Nó sử dụng logic format riêng của feature hóa đơn vì format số hóa đơn là mối quan tâm của hóa đơn, không phải mối quan tâm toàn cục.
Phần phản trực giác: điều này có nghĩa là bạn sẽ có một số trùng lặp. Feature hóa đơn có thể có format.ts và feature thanh toán cũng có thể có format.ts. Không sao cả. Thực ra điều đó tốt hơn một formatters.ts dùng chung mà 14 feature phụ thuộc vào và không ai dám sửa an toàn.
Tôi đã chống lại điều này rất lâu. DRY là giáo điều. Nhưng tôi đã thấy các file utility dùng chung trở thành những file nguy hiểm nhất trong codebase, những file mà một "sửa nhỏ" cho một feature làm hỏng ba feature khác. Colocation với trùng lặp có kiểm soát dễ bảo trì hơn chia sẻ ép buộc.
Cấu Trúc Thư Mục Tôi Thực Sự Sử Dụng#
Đây là cây thư mục đầy đủ. Nó đã được thử nghiệm thực chiến qua hai ứng dụng production với team 4-8 người:
src/
├── app/
│ ├── (public)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── pricing/
│ │ │ └── page.tsx
│ │ └── blog/
│ │ ├── page.tsx
│ │ └── [slug]/
│ │ └── page.tsx
│ ├── (auth)/
│ │ ├── layout.tsx
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── invoices/
│ │ │ ├── page.tsx
│ │ │ ├── [id]/
│ │ │ │ └── page.tsx
│ │ │ └── new/
│ │ │ └── page.tsx
│ │ └── settings/
│ │ └── page.tsx
│ ├── api/
│ │ └── webhooks/
│ │ └── stripe/
│ │ └── route.ts
│ ├── layout.tsx
│ └── not-found.tsx
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── login-form.tsx
│ │ │ ├── register-form.tsx
│ │ │ └── oauth-buttons.tsx
│ │ ├── actions/
│ │ │ ├── login.ts
│ │ │ └── register.ts
│ │ ├── hooks/
│ │ │ └── use-session.ts
│ │ ├── types.ts
│ │ └── constants.ts
│ ├── invoicing/
│ │ ├── components/
│ │ │ ├── invoice-table.tsx
│ │ │ ├── invoice-row.tsx
│ │ │ ├── invoice-form.tsx
│ │ │ └── invoice-pdf.tsx
│ │ ├── actions/
│ │ │ ├── create-invoice.ts
│ │ │ └── update-status.ts
│ │ ├── hooks/
│ │ │ └── use-invoice-actions.ts
│ │ ├── api/
│ │ │ └── client.ts
│ │ ├── utils/
│ │ │ └── format.ts
│ │ └── types.ts
│ └── billing/
│ ├── components/
│ ├── actions/
│ ├── hooks/
│ └── types.ts
├── shared/
│ ├── components/
│ │ ├── ui/
│ │ │ ├── button.tsx
│ │ │ ├── input.tsx
│ │ │ ├── modal.tsx
│ │ │ └── toast.tsx
│ │ └── layout/
│ │ ├── header.tsx
│ │ ├── footer.tsx
│ │ └── sidebar.tsx
│ ├── hooks/
│ │ ├── use-debounce.ts
│ │ └── use-media-query.ts
│ ├── lib/
│ │ ├── db.ts
│ │ ├── auth.ts
│ │ ├── stripe.ts
│ │ └── email.ts
│ └── types/
│ └── global.ts
└── config/
├── site.ts
└── navigation.ts
Một vài điểm cần chú ý:
app/chỉ chứa logic routing. Các file page rất mỏng. Chúng import từfeatures/và tổ hợp lại. Một file page nên từ 20-40 dòng, tối đa.features/là nơi code thực sự sống. Mỗi feature là tự đủ. Bạn có thể xóafeatures/invoicing/và không gì khác bị hỏng (ngoại trừ các trang import từ nó, đó chính xác là mối liên kết bạn muốn).shared/không phải là nơi đổ rác. Nó có các danh mục con nghiêm ngặt. Chi tiết bên dưới.
Route Group: Hơn Cả Sắp Xếp#
Route group, các thư mục (đặt trong ngoặc), là một trong những feature ít được sử dụng nhất trong App Router. Chúng không ảnh hưởng đến URL chút nào. (dashboard)/invoices/page.tsx render tại /invoices, không phải /dashboard/invoices.
Nhưng sức mạnh thực sự là cô lập layout. Mỗi route group có layout.tsx riêng:
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) {
redirect("/login");
}
return (
<div className="flex min-h-screen">
<Sidebar user={session.user} />
<main className="flex-1 p-6">{children}</main>
</div>
);
}// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header />
<main>{children}</main>
<Footer />
</>
);
}Kiểm tra auth xảy ra một lần ở cấp layout, không phải trong mỗi trang. Các trang public có một shell hoàn toàn khác. Các trang auth không có layout nào cả, chỉ một card căn giữa. Đây là cách rõ ràng, tường minh, và khó làm sai.
Tôi đã thấy các team đặt kiểm tra auth trong middleware, trong từng trang riêng lẻ, trong component, tất cả cùng lúc. Hãy chọn một nơi. Đối với hầu hết ứng dụng, layout của route group là nơi đó.
Thư Mục shared/ Không Phải Là utils/#
Cách nhanh nhất để tạo ra cơn ác mộng bảo trì là file lib/utils.ts. Nó bắt đầu nhỏ. Rồi nó trở thành ngăn kéo linh tinh của codebase, nơi mà mọi hàm không có chỗ rõ ràng kết thúc ở đó.
Đây là quy tắc của tôi: thư mục shared/ cần lý do chính đáng. Thứ gì đó được đưa vào shared/ chỉ khi:
- Nó được sử dụng bởi 3+ feature, và
- Nó thực sự generic (không phải business-logic đội lốt generic)
Một hàm format ngày tháng cho ngày hết hạn hóa đơn? Đó thuộc về features/invoicing/utils/. Một hàm format ngày tháng nhận Date và trả về chuỗi locale? Đó có thể đưa vào shared/.
Thư mục con shared/lib/ dành riêng cho tích hợp bên thứ ba và hạ tầng: database client, thư viện auth, nhà cung cấp thanh toán. Đây là những thứ toàn bộ ứng dụng phụ thuộc vào nhưng không feature đơn lẻ nào sở hữu.
// shared/lib/db.ts — cái này thuộc về shared
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
// features/invoicing/utils/format.ts — cái này KHÔNG thuộc về shared
export function formatInvoiceNumber(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const seq = Math.floor(Math.random() * 9000) + 1000;
return `INV-${year}-${seq}`;
}Nếu bạn đang vươn tới shared/utils/ và không thể đặt tên file cụ thể hơn helpers.ts, hãy dừng lại. Code đó có lẽ thuộc về một feature.
Server vs. Client Component: Vạch Ranh Giới Có Chủ Đích#
Đây là một pattern tôi thấy liên tục trong các codebase có vấn đề hiệu năng:
// Trông có vẻ hợp lý nhưng là sai lầm
"use client";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
export default function InvoicesPage() {
const { data } = useInvoiceActions();
return (
<div>
<InvoiceFilters />
<InvoiceTable invoices={data} />
</div>
);
}Trang được đánh dấu "use client". Bây giờ mọi thứ nó import, InvoiceTable, InvoiceFilters, hook, mọi dependency của mọi dependency, đều nằm trong client bundle. Bảng 200 dòng có thể được render trên server với zero JavaScript giờ đang được ship đến trình duyệt.
Cách sửa là đẩy "use client" xuống các component lá thực sự cần nó:
// app/(dashboard)/invoices/page.tsx — Server Component (không có directive)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
export default async function InvoicesPage() {
const invoices = await db.query.invoices.findMany({
orderBy: (inv, { desc }) => [desc(inv.createdAt)],
});
return (
<div>
<InvoiceFilters /> {/* Client Component — có filter tương tác */}
<InvoiceTable data={invoices} /> {/* Server Component — chỉ render các dòng */}
</div>
);
}// features/invoicing/components/invoice-filters.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
export function InvoiceFilters() {
const router = useRouter();
const params = useSearchParams();
const setStatus = (status: string) => {
const next = new URLSearchParams(params.toString());
next.set("status", status);
router.push(`?${next.toString()}`);
};
return (
<div className="flex gap-2">
{["all", "draft", "sent", "paid"].map((s) => (
<button
key={s}
onClick={() => setStatus(s)}
className={params.get("status") === s ? "font-bold" : ""}
>
{s}
</button>
))}
</div>
);
}Quy tắc tôi tuân theo: mọi component là Server Component cho đến khi nó cần useState, useEffect, event handler, hoặc browser API. Chỉ khi đó mới thêm "use client", và cố gắng làm component đó càng nhỏ càng tốt.
Pattern children là bạn tốt nhất của bạn ở đây. Một Client Component có thể nhận Server Component làm children:
"use client";
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* children có thể là Server Component */}
</div>
);
}Đây là cách bạn giữ 90% cây component trên server trong khi vẫn có tương tác ở nơi cần thiết.
Barrel File: Kẻ Giết Build Thầm Lặng#
Tôi từng đặt index.ts trong mỗi thư mục. Import sạch đẹp, đúng không?
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";Sau đó tôi có thể import như thế này:
import { InvoiceTable } from "@/features/invoicing/components";Trông đẹp. Đây là vấn đề: khi bundler thấy import đó, nó phải đánh giá toàn bộ barrel file để tìm ra InvoiceTable là gì. Điều đó có nghĩa là nó tải InvoiceRow, InvoiceForm, và InvoicePdf luôn, dù bạn chỉ cần table.
Trong một dự án, việc loại bỏ barrel file nội bộ đã giảm số module từ 11.000 xuống 3.500 trong quá trình phát triển. Dev server từ 5-10 giây tải trang xuống dưới 2 giây. JavaScript tải lần đầu giảm từ hơn một megabyte xuống khoảng 200 KB.
Vercel đã xây dựng optimizePackageImports trong next.config.ts để xử lý điều này cho package bên thứ ba từ node_modules. Nhưng nó không sửa barrel file của bạn. Đối với code của bạn, câu trả lời đơn giản hơn: đừng sử dụng chúng.
// Làm thế này
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
// Không phải thế này
import { InvoiceTable } from "@/features/invoicing/components";Đúng, đường dẫn import dài hơn. Build nhanh hơn, bundle nhỏ hơn, và dev server không bị nghẹt. Đó là đánh đổi tôi chấp nhận mọi lúc.
Ngoại lệ duy nhất: API công khai của feature. Nếu features/invoicing/ export một index.ts duy nhất ở cấp gốc của feature chỉ với 2-3 thứ mà feature khác được phép import, thì ổn. Nó đóng vai trò ranh giới, không phải phím tắt tiện lợi. Nhưng barrel file bên trong thư mục con như components/index.ts hay hooks/index.ts? Loại bỏ chúng.
Trang Mỏng, Feature Dày#
Quy tắc kiến trúc quan trọng nhất tôi tuân theo là: file page là dây nối, không phải logic.
Một file page trong app/ nên làm ba việc:
- Fetch dữ liệu (nếu là Server Component)
- Import các component từ feature
- Tổ hợp chúng lại
Thế thôi. Không business logic. Không render có điều kiện phức tạp. Không cây JSX 200 dòng. Nếu file page vượt quá 40 dòng, bạn đang đặt code feature sai chỗ.
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
interface Props {
params: Promise<{ id: string }>;
}
export default async function InvoicePage({ params }: Props) {
const { id } = await params;
const invoice = await db.query.invoices.findFirst({
where: (inv, { eq }) => eq(inv.id, id),
});
if (!invoice) notFound();
return <InvoiceDetail invoice={invoice} />;
}Mười hai dòng. Trang fetch dữ liệu cần thiết, xử lý trường hợp not-found, và ủy thác mọi thứ khác cho feature. Nếu giao diện chi tiết hóa đơn thay đổi, bạn sửa features/invoicing/, không phải app/. Nếu route thay đổi, bạn di chuyển file page, và code feature không quan tâm.
Đây là cách bạn có codebase mà 15 developer có thể làm việc đồng thời mà không giẫm chân nhau.
Sự Thật Khó Chịu#
Không có cấu trúc thư mục nào ngăn chặn được code tệ. Tôi đã thấy những dự án được tổ chức đẹp đẽ đầy những abstraction kinh khủng, và những cấu trúc phẳng lộn xộn mà dễ làm việc vì bản thân code rõ ràng.
Cấu trúc là công cụ, không phải mục tiêu. Cách tiếp cận theo feature tôi mô tả ở đây hiệu quả vì nó tối ưu cho điều quan trọng nhất ở quy mô lớn: một developer chưa từng thấy codebase này có thể tìm được thứ họ cần và thay đổi nó mà không phá vỡ thứ khác không?
Nếu cấu trúc hiện tại của bạn trả lời "có" cho câu hỏi đó, đừng refactor nó chỉ vì một bài blog bảo bạn làm vậy, kể cả bài này.
Nhưng nếu developer của bạn dành nhiều thời gian điều hướng codebase hơn viết code, nếu "cái này để ở đâu?" là câu hỏi lặp đi lặp lại trong PR, nếu thay đổi billing bằng cách nào đó làm hỏng dashboard, thì cấu trúc đang chống lại bạn. Và các pattern ở trên là cách tôi đã sửa điều đó, mọi lần.