大规模 Next.js 项目结构:我用踩坑换来的经验
关于组织 Next.js 代码库的血泪教训——让项目不会在自身重量下崩塌。基于功能的架构、路由分组、服务端/客户端边界、barrel file 陷阱,以及一个你可以直接借鉴的真实目录结构。
每个 Next.js 项目一开始都很干净。几个页面,一个 components 文件夹,可能再加一个 lib/utils.ts。你觉得效率很高。你觉得组织得很好。
然后六个月过去了。三个开发者加入。components 文件夹已经有 140 个文件了。有人创建了 utils2.ts,因为 utils.ts 已经 800 行了。没人知道哪个 layout 套的是哪个路由。构建时间四分钟,你也不确定为什么。
我经历过这个循环好几次。接下来要分享的,是我现在真正在做的事情——这些经验大部分是先发布了错误的版本之后才学到的。
默认结构很快就会崩溃#
Next.js 文档建议了一种项目结构。它没问题。对于个人博客或小工具来说够用了。但当你有超过两个开发者在同一个代码库上工作时,"按类型分类"的方式就开始出现裂痕:
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 ← the beginning of the end
app/
page.tsx
dashboard/
page.tsx
问题不在于这是"错误的"。而在于它随功能线性增长,但理解力并不会跟着增长。当一个新开发者需要处理发票功能时,他们得同时打开 components/、hooks/、utils/、types/ 和 app/。发票功能散布在五个目录中。没有一个地方能告诉你"发票功能长什么样"。
我亲眼看到一个四人团队花了一整个迭代周期重构成这种结构,确信这是"整洁"的方式。三个月内他们又开始困惑了,只不过多了更多文件夹。
基于功能的就近放置始终胜出#
在真实团队中经受住考验的模式是基于功能的就近放置。理念很简单:与某个功能相关的一切都放在一起。
不是"所有组件放一个文件夹,所有 hooks 放另一个"。恰恰相反。发票相关的所有组件、hooks、类型和工具函数都放在 features/invoicing/ 里。认证相关的一切都放在 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 };
}注意这些 import 路径。它们全是相对路径,全在功能模块内部。这个 hook 不会去访问某个全局的 hooks/ 文件夹或共享的 utils/format.ts。它使用的是发票功能自己的格式化逻辑,因为发票号码的格式化是发票功能的关注点,而不是全局的。
反直觉的是:这意味着你会有一些重复代码。 你的发票功能可能有一个 format.ts,你的账单功能可能也有一个 format.ts。没关系。这实际上比一个被 14 个功能依赖、没人敢安全修改的共享 formatters.ts 要好。
我抵触这种做法很长时间。DRY 原则就是信条。但我见过共享的工具文件变成代码库中最危险的文件——为一个功能做的"小修复"导致另外三个功能崩溃。通过就近放置并控制重复,比强制共享更易维护。
我实际使用的目录结构#
这是完整的目录树。它已经在两个生产应用中经过 4-8 人团队的实战检验:
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
有几点需要注意:
app/只包含路由逻辑。 页面文件应该很薄。它们从features/导入并组合。一个页面文件最多 20-40 行。features/是真正代码所在的地方。 每个功能都是自包含的。你可以删掉features/invoicing/,其他什么都不会坏(除了那些从它导入的页面,而这恰恰是你想要的耦合方式)。shared/不是垃圾桶。 它有严格的子分类。下面会详细说明。
路由分组:不仅仅是组织方式#
路由分组,也就是带 (括号) 的文件夹,是 App Router 中最被低估的功能之一。它们完全不影响 URL。(dashboard)/invoices/page.tsx 渲染的路径是 /invoices,而不是 /dashboard/invoices。
但真正强大的是布局隔离。每个路由分组都有自己的 layout.tsx:
// 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 />
</>
);
}认证检查在 layout 层级只做一次,而不是在每个页面里都做。公开页面有完全不同的外壳。认证页面完全没有 layout——只有一个居中的卡片。这种方式干净、明确、不容易出错。
我见过团队把认证检查放在 middleware 里、放在各个页面里、放在组件里——而且同时放在所有这些地方。选一个地方就好。对大多数应用来说,路由分组的 layout 就是那个地方。
shared/ 文件夹不是 utils/#
创造维护噩梦最快的方式就是搞一个 lib/utils.ts 文件。它一开始很小。然后它就变成了代码库的杂物抽屉——每个找不到明确归属的函数都被扔进去。
这是我的原则:shared/ 文件夹需要正当理由。 只有满足以下条件才能放入 shared/:
- 被 3 个以上的功能使用,并且
- 确实是通用的(不是披着通用外衣的业务逻辑)
一个用来格式化发票到期日的日期格式化函数?那应该放在 features/invoicing/utils/。一个接收 Date 返回本地化字符串的日期格式化函数?那可以放在 shared/。
shared/lib/ 子文件夹专门用于第三方集成和基础设施:数据库客户端、认证库、支付提供商。这些是整个应用依赖的东西,但没有哪个单独的功能拥有它们。
// shared/lib/db.ts — 这个属于 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 — 这个不属于 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}`;
}如果你想往 shared/utils/ 里放东西,却无法给文件起一个比 helpers.ts 更具体的名字,那就停下来。那段代码大概应该放在某个功能模块里。
服务端 vs. 客户端组件:刻意划清界限#
以下是我在有性能问题的代码库中经常看到的模式:
// 看起来合理但其实是个错误
"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>
);
}页面标记了 "use client"。现在它导入的所有东西——InvoiceTable、InvoiceFilters、hook,以及每个依赖的依赖——全都进了客户端 bundle。你那个本可以在服务端零 JavaScript 渲染的 200 行表格,现在要发送到浏览器了。
解决办法是把 "use client" 下推到真正需要它的叶子组件:
// app/(dashboard)/invoices/page.tsx — 服务端组件(没有指令)
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 /> {/* 客户端组件 — 有交互式筛选器 */}
<InvoiceTable data={invoices} /> {/* 服务端组件 — 只是渲染行 */}
</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>
);
}我遵循的规则是:每个组件默认是服务端组件,直到它需要 useState、useEffect、事件处理器或浏览器 API。 只有在那时才添加 "use client",并且尽量让那个组件尽可能小。
children 模式是你最好的朋友。客户端组件可以接收服务端组件作为 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 可以是服务端组件 */}
</div>
);
}这就是你如何让 90% 的组件树留在服务端,同时在需要的地方保持交互性。
Barrel 文件:无声的构建杀手#
我曾经在每个文件夹里都放一个 index.ts。导入看起来很清爽,对吧?
// 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";然后我就可以这样导入:
import { InvoiceTable } from "@/features/invoicing/components";看起来不错。问题是:当打包器看到这个导入时,它必须评估整个 barrel 文件才能确定 InvoiceTable 是什么。这意味着它也会加载 InvoiceRow、InvoiceForm 和 InvoicePdf,即使你只需要 table。
在一个项目中,移除内部 barrel 文件后,开发阶段的模块数量从 11,000 降到了 3,500。开发服务器的页面加载时间从 5-10 秒降到了 2 秒以内。首次加载的 JavaScript 从超过 1MB 降到了大约 200 KB。
Vercel 在 next.config.ts 中构建了 optimizePackageImports 来处理 node_modules 中的第三方包。但它无法修复你自己的 barrel 文件。对于你自己的代码,答案更简单:不要使用它们。
// 这样做
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
// 不要这样做
import { InvoiceTable } from "@/features/invoicing/components";是的,导入路径更长了。但你的构建更快了,bundle 更小了,开发服务器也不会卡顿了。这个交换我每次都愿意做。
唯一的例外:功能模块的公共 API。如果 features/invoicing/ 在功能根目录暴露一个 index.ts,里面只有 2-3 个允许其他功能导入的东西,那没问题。它充当的是边界,而不是便捷快捷方式。但子文件夹内的 barrel 文件,比如 components/index.ts 或 hooks/index.ts?干掉它们。
薄页面,厚功能#
我遵循的最重要的架构规则是:页面文件是接线,不是逻辑。
app/ 中的页面文件应该只做三件事:
- 获取数据(如果是服务端组件)
- 导入功能组件
- 将它们组合在一起
就这些。没有业务逻辑。没有复杂的条件渲染。没有 200 行的 JSX 树。如果一个页面文件超过 40 行,你就是把功能代码放错了地方。
// 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} />;
}十二行。页面获取它需要的数据,处理 not-found 情况,其余一切委托给功能模块。如果发票详情 UI 变了,你编辑 features/invoicing/,而不是 app/。如果路由变了,你移动页面文件,功能代码不受影响。
这就是你如何构建一个让 15 个开发者同时工作而不互相干扰的代码库。
残酷的现实#
没有任何目录结构能防止烂代码。我见过组织得漂漂亮亮但充满糟糕抽象的项目,也见过一团乱麻的扁平结构但因为代码本身足够清晰而很容易上手。
结构是工具,不是目标。我描述的这种基于功能的方法之所以有效,是因为它优化了在规模化时最重要的那件事:一个从未见过这个代码库的开发者,能否找到他们需要的东西并修改它,而不破坏其他部分?
如果你当前的结构对这个问题的回答是"能",那就不要因为一篇博文让你去重构——即使是这篇。
但如果你的开发者花在浏览代码库上的时间比写代码还多,如果"这个应该放哪里?"是 PR 中反复出现的问题,如果对账单的修改莫名其妙地搞坏了仪表盘——那么你的结构在跟你作对。上面的这些模式就是我每次修复这类问题的方法。