项目结构与组织
深入理解 Next.js 项目的文件夹和文件约定,掌握项目组织的最佳实践。
概述
Next.js 通过特定的文件夹和文件约定来组织项目结构。理解这些约定对于构建可维护的应用至关重要。
顶层文件夹
顶层文件夹用于组织应用代码和静态资源。
app
App Router 的根目录,包含所有路由和布局文件。
pages
Pages Router 的根目录(旧路由系统)。
public
存放静态资源,可通过根路径 / 直接访问。
// public/avatars/me.png 可通过 /avatars/me.png 访问
import Image from 'next/image'
export function Avatar({ id, alt }) {
return <Image src={`/avatars/${id}.png`} alt={alt} width="64" height="64" />
}注意事项:
public文件夹中的资源不会被安全缓存,因为它们可能随时变化- 默认缓存头为
Cache-Control: public, max-age=0 - 不要在
public中放置robots.txt、favicon.ico等元数据文件,应使用app目录中的特殊元数据文件
src
可选的应用源代码文件夹,用于分离应用代码和配置文件。
使用 src 文件夹的注意事项:
/public目录必须保留在项目根目录- 配置文件(
package.json、next.config.js、tsconfig.json)必须保留在根目录 .env.*文件必须保留在根目录- 如果根目录存在
app或pages,则src/app或src/pages会被忽略 - 使用
src时,应将其他应用文件夹(如/components、/lib)也移入其中 - 使用 Tailwind CSS 时,需在
tailwind.config.js的content部分添加/src前缀 - 使用 TypeScript 路径别名(如
@/*)时,需更新tsconfig.json中的paths对象
顶层文件
Next.js 配置文件
next.config.js
Next.js 的配置文件。
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
domains: ['example.com'],
},
}
module.exports = nextConfigpackage.json
项目依赖和脚本。
{
"name": "my-app",
"version": "0.1.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.0.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}.env.local
本地环境变量(不会提交到 Git)。
DATABASE_URL=postgresql://localhost:5432/mydb
API_KEY=your_secret_key.env
所有环境的默认环境变量。
NEXT_PUBLIC_API_URL=https://api.example.com路由文件约定
layout.tsx
定义共享 UI 布局,会包裹子路由。
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<section>
<nav>Dashboard Navigation</nav>
{children}
</section>
)
}根布局特性:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh">
<body>{children}</body>
</html>
)
}- 必须包含
<html>和<body>标签 - 是必需的,不能删除
page.tsx
定义路由的唯一 UI,使路由可公开访问。
// app/blog/page.tsx
export default function BlogPage() {
return <h1>博客</h1>
}loading.tsx
基于 Suspense 的加载 UI。
export default function Loading() {
return <div>加载中...</div>
}实际应用:
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900" />
</div>
)
}error.tsx
错误 UI 边界。
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>出错了!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>重试</button>
</div>
)
}error.tsx 必须是客户端组件。
not-found.tsx
404 未找到 UI。
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>未找到</h2>
<p>无法找到请求的资源</p>
<Link href="/">返回首页</Link>
</div>
)
}route.tsx
服务端 API 端点。
// app/api/users/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const users = await fetchUsers()
return NextResponse.json(users)
}
export async function POST(request: Request) {
const body = await request.json()
const user = await createUser(body)
return NextResponse.json(user, { status: 201 })
}template.tsx
类似 layout,但在导航时会重新渲染。
export default function Template({ children }: { children: React.ReactNode }) {
return <div className="animate-fade-in">{children}</div>
}layout vs template:
layout在导航时保持状态template在每次导航时创建新实例
default.tsx
并行路由的回退 UI。
export default function Default() {
return <div>默认内容</div>
}嵌套路由
文件夹定义 URL 段,嵌套文件夹创建嵌套段。
关键概念:
- 文件夹定义 URL 段
- 嵌套文件夹创建嵌套段
- 任何级别的布局都会包裹其子段
- 只有存在
page或route文件时,路由才会公开
动态路由
使用方括号创建动态段。
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <h1>文章:{slug}</h1>
}捕获所有段
使用 [...folder] 捕获所有后续段。
// app/docs/[...slug]/page.tsx
export default async function Docs({
params,
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
return <h1>文档:{slug.join('/')}</h1>
}可选捕获所有段
使用 [[...folder]] 使段可选。
// app/shop/[[...categories]]/page.tsx
export default async function Shop({
params,
}: {
params: Promise<{ categories?: string[] }>
}) {
const { categories } = await params
return <h1>分类:{categories?.join('/') || '全部'}</h1>
}路由组
使用括号创建路由组,用于组织路由而不影响 URL。
路由组的用途:
1. 按团队或功能组织路由
2. 创建多个根布局
// app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh">
<body className="marketing">{children}</body>
</html>
)
}
// app/(shop)/layout.tsx
export default function ShopLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh">
<body className="shop">{children}</body>
</html>
)
}3. 将特定段选择加入布局
4. 为特定路由应用加载骨架
路由组的注意事项:
- 不同根布局之间的导航会触发完整页面重新加载
- 不同组中的路由不应解析为相同的 URL 路径
- 使用多个根布局时,确保首页路由
/定义在其中一个路由组中
私有文件夹
使用下划线前缀创建私有文件夹,排除在路由之外。
私有文件夹的用途:
- 分离 UI 逻辑和路由逻辑
- 在项目和 Next.js 生态系统中一致地组织内部文件
- 在代码编辑器中排序和分组文件
- 避免与未来 Next.js 文件约定的潜在命名冲突
替代方案:
如果不想使用下划线前缀,可以将私有文件放在 app 外部:
元数据文件约定
Next.js 支持特殊的元数据文件,用于定义应用元数据。
favicon、icon 和 apple-icon
动态生成图标:
// app/icon.tsx
import { ImageResponse } from 'next/og'
export const size = { width: 32, height: 32 }
export const contentType = 'image/png'
export default function Icon() {
return new ImageResponse(
(
<div
style={{
fontSize: 24,
background: 'black',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
}}
>
A
</div>
),
{ ...size }
)
}manifest.json
Web 应用清单。
{
"name": "My Next.js App",
"short_name": "Next App",
"description": "A Next.js application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}动态生成清单:
// app/manifest.ts
import { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'My Next.js App',
short_name: 'Next App',
description: 'A Next.js application',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
],
}
}opengraph-image 和 twitter-image
Open Graph 和 Twitter 卡片图片。
动态生成:
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const alt = 'About Acme'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image() {
return new ImageResponse(
(
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
About Acme
</div>
),
{ ...size }
)
}robots.txt
搜索引擎爬虫规则。
User-Agent: *
Allow: /
Disallow: /admin/
Sitemap: https://example.com/sitemap.xml动态生成:
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: '/admin/',
},
sitemap: 'https://example.com/sitemap.xml',
}
}sitemap.xml
站点地图。
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
{
url: 'https://example.com/about',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: 'https://example.com/blog',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.5,
},
]
}动态生成(从数据库):
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetchPosts()
const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly',
priority: 0.7,
}))
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
...postEntries,
]
}项目组织策略
1. 将项目文件放在 app 外部
├── app/
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ └── Button.tsx
└── lib/
└── utils.ts2. 将项目文件放在 app 内的顶层文件夹中
app/
├── components/
│ └── Button.tsx
├── lib/
│ └── utils.ts
├── layout.tsx
└── page.tsx3. 按功能或路由拆分项目文件
app/
├── dashboard/
│ ├── _components/
│ │ └── Chart.tsx
│ ├── _lib/
│ │ └── data.ts
│ └── page.tsx
└── blog/
├── _components/
│ └── PostCard.tsx
└── page.tsx最佳实践
1. 使用路由组组织代码
将相关路由分组,共享布局和逻辑。
app/
├── (auth)/
│ ├── layout.tsx # 认证布局
│ ├── login/
│ └── register/
└── (dashboard)/
├── layout.tsx # 仪表板布局
├── overview/
└── settings/2. 使用私有文件夹隔离组件
将路由特定的组件和工具放在私有文件夹中。
app/
└── dashboard/
├── _components/
│ ├── Sidebar.tsx
│ └── Header.tsx
├── _lib/
│ └── api.ts
└── page.tsx3. 使用 src 文件夹分离代码和配置
对于大型项目,使用 src 文件夹保持根目录整洁。
src/
├── app/
├── components/
└── lib/
next.config.js
package.json
tsconfig.json4. 合理使用元数据文件
使用动态元数据文件而非静态文件,以便更好地控制和灵活性。
// 优先使用
export default function sitemap(): MetadataRoute.Sitemap { }
// 而非
// sitemap.xml5. 保持一致的文件命名
使用一致的命名约定,例如:
- 组件:PascalCase(
Button.tsx) - 工具函数:camelCase(
formatDate.ts) - 常量:UPPER_SNAKE_CASE(
API_ENDPOINTS.ts)
总结
Next.js 的项目结构通过约定优于配置的方式,提供了清晰的组织模式:
- 使用
app目录定义路由 - 使用路由组组织相关路由
- 使用私有文件夹隔离非路由文件
- 使用元数据文件自动生成 SEO 相关内容
- 使用
src文件夹分离应用代码和配置
理解并遵循这些约定,可以构建结构清晰、易于维护的 Next.js 应用。
上次更新于