Next.js

项目结构与组织

深入理解 Next.js 项目的文件夹和文件约定,掌握项目组织的最佳实践。

概述

Next.js 通过特定的文件夹和文件约定来组织项目结构。理解这些约定对于构建可维护的应用至关重要。

顶层文件夹

顶层文件夹用于组织应用代码和静态资源。

app

App Router 的根目录,包含所有路由和布局文件。

layout.tsx # 根布局
page.tsx # 首页
page.tsx # /about 路由
layout.tsx # 博客布局
page.tsx # /blog 路由

pages

Pages Router 的根目录(旧路由系统)。

index.tsx # 首页
about.tsx # /about 路由
index.tsx # /blog 路由

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.txtfavicon.ico 等元数据文件,应使用 app 目录中的特殊元数据文件

src

可选的应用源代码文件夹,用于分离应用代码和配置文件。

layout.tsx
page.tsx
components/ # 组件
lib/ # 工具函数

使用 src 文件夹的注意事项:

  • /public 目录必须保留在项目根目录
  • 配置文件(package.jsonnext.config.jstsconfig.json)必须保留在根目录
  • .env.* 文件必须保留在根目录
  • 如果根目录存在 apppages,则 src/appsrc/pages 会被忽略
  • 使用 src 时,应将其他应用文件夹(如 /components/lib)也移入其中
  • 使用 Tailwind CSS 时,需在 tailwind.config.jscontent 部分添加 /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 = nextConfig

package.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 段,嵌套文件夹创建嵌套段。

layout.tsx # /dashboard 布局
page.tsx # /dashboard
page.tsx # /dashboard/settings
page.tsx # /dashboard/analytics

关键概念:

  • 文件夹定义 URL 段
  • 嵌套文件夹创建嵌套段
  • 任何级别的布局都会包裹其子段
  • 只有存在 pageroute 文件时,路由才会公开

动态路由

使用方括号创建动态段。

page.tsx # /blog/:slug
// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  return <h1>文章:{slug}</h1>
}

捕获所有段

使用 [...folder] 捕获所有后续段。

page.tsx # /docs/a, /docs/a/b, /docs/a/b/c
// 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]] 使段可选。

page.tsx # /shop, /shop/clothes, /shop/clothes/tops
// 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。

page.tsx # /about
page.tsx # /blog
page.tsx # /cart
page.tsx # /checkout

路由组的用途:

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. 将特定段选择加入布局

layout.tsx # 仅应用于 account 和 cart
page.tsx
page.tsx
page.tsx # 不使用 shop 布局

4. 为特定路由应用加载骨架

loading.tsx # 仅应用于 overview
page.tsx
page.tsx # 不使用 loading

路由组的注意事项:

  • 不同根布局之间的导航会触发完整页面重新加载
  • 不同组中的路由不应解析为相同的 URL 路径
  • 使用多个根布局时,确保首页路由 / 定义在其中一个路由组中

私有文件夹

使用下划线前缀创建私有文件夹,排除在路由之外。

utils.ts # 不会创建路由
Button.tsx # 不会创建路由
page.tsx # /dashboard

私有文件夹的用途:

  • 分离 UI 逻辑和路由逻辑
  • 在项目和 Next.js 生态系统中一致地组织内部文件
  • 在代码编辑器中排序和分组文件
  • 避免与未来 Next.js 文件约定的潜在命名冲突

替代方案:

如果不想使用下划线前缀,可以将私有文件放在 app 外部:

page.tsx
Button.tsx
utils.ts

元数据文件约定

Next.js 支持特殊的元数据文件,用于定义应用元数据。

favicon、icon 和 apple-icon

favicon.ico # Favicon
icon.png # App Icon
apple-icon.png # Apple Touch 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 卡片图片。

opengraph-image.jpg # Open Graph 图片
twitter-image.jpg # 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.ts

2. 将项目文件放在 app 内的顶层文件夹中

app/
├── components/
│   └── Button.tsx
├── lib/
│   └── utils.ts
├── layout.tsx
└── page.tsx

3. 按功能或路由拆分项目文件

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.tsx

3. 使用 src 文件夹分离代码和配置

对于大型项目,使用 src 文件夹保持根目录整洁。

src/
├── app/
├── components/
└── lib/
next.config.js
package.json
tsconfig.json

4. 合理使用元数据文件

使用动态元数据文件而非静态文件,以便更好地控制和灵活性。

// 优先使用
export default function sitemap(): MetadataRoute.Sitemap { }

// 而非
// sitemap.xml

5. 保持一致的文件命名

使用一致的命名约定,例如:

  • 组件:PascalCase(Button.tsx
  • 工具函数:camelCase(formatDate.ts
  • 常量:UPPER_SNAKE_CASE(API_ENDPOINTS.ts

总结

Next.js 的项目结构通过约定优于配置的方式,提供了清晰的组织模式:

  • 使用 app 目录定义路由
  • 使用路由组组织相关路由
  • 使用私有文件夹隔离非路由文件
  • 使用元数据文件自动生成 SEO 相关内容
  • 使用 src 文件夹分离应用代码和配置

理解并遵循这些约定,可以构建结构清晰、易于维护的 Next.js 应用。

在 GitHub 上编辑

上次更新于