next.js基础


pnpm dev 调试运行
Remove-Item -Recurse -Force .next 清理css缓存
pnpm build 构建
pnpm start 构建后运行

Next.js 是什么

一个 React 框架。在 React 之上多帮你做了三件事:

  1. 路由:你在 app/ 下建一个文件夹,URL 就自动出来了,不用配 react-router。
  2. 服务端渲染(SSR / SSG):默认在服务器上把页面渲染成 HTML 再发给浏览器,对 SEO 和首屏速度友好。
  3. 打包优化:内置 Webpack / Turbopack,分代码、压缩、图片优化、字体优化这些不用手动配。

项目目录结构

最小结构

lua
my-app/
├── app/                  ← 路由 + 页面,全部放这里
│   ├── layout.tsx        ← 根布局(每个页面都套这一层)
│   ├── page.tsx          ← 对应 URL "/",即首页,创建"(site)"目录则升级成路由组
│   └── globals.css       ← 全局 CSS
├── public/               ← 静态资源(直接以 URL 暴露)
├── package.json          ← 依赖清单 + 脚本命令
├── next.config.ts        ← Next.js 配置
└── tsconfig.json         ← TypeScript 配置

关键规则(App Router):

文件名作用
page.tsx这个文件夹对应一个 URL,导出的组件就是这个页面
layout.tsx这个文件夹(及其子路由)共用的外壳
loading.tsx加载中的占位(流式渲染时显示)
error.tsx出错时的兜底 UI
not-found.tsx404 页面
[xxx] 文件夹动态路由参数。例:note/[id]/page.tsx → URL /note/123,组件能拿到 id="123"
(xxx) 文件夹路由分组,括号不进 URL。常用于"共享同一个 layout 但不影响路径"

关键概念解释

Server Components vs Client Components

App Router 里所有组件默认在服务器上跑(Server Component)。好处:能直接 await 数据库、不把 React 代码送到浏览器、对 SEO 好。

只有需要交互(onClickuseStateuseEffect)的组件才在文件顶部加:

tsx
"use client";

这一行让该组件变成 Client Component,会打包进送给浏览器的 JS bundle。

这套博客的做法:页面骨架走 Server Component(SSR),按钮 / 编辑器 / 复制按钮这些局部加 "use client"。这样爬虫拿到完整 HTML,用户操作又有交互。

Server Actions

在文件顶部写 "use server",这个文件里的 async 函数就可以从客户端直接调用,但代码只在服务器上执行。本项目用它来登录、增删改文章。见 lib/actions.ts

路径别名 @/

tsconfig.json 里配了 @/* → ./*,所以 import { prisma } from "@/lib/prisma" 实际上指的是项目根的 lib/prisma.ts。比写一堆 ../../../ 清爽。

构建产物".next"目录

lua
.next/
├── BUILD_ID                ← 本次构建的唯一 ID(哈希),CDN 缓存键的一部分
├── build-manifest.json     ← 路由 → 用到哪些 JS/CSS 的映射
├── app-build-manifest.json ← App Router 的映射版本
├── routes-manifest.json    ← 路由表 + 重写 / 重定向规则
├── prerender-manifest.json ← 哪些页面被预渲染、什么时候 revalidate
├── react-loadable-manifest.json
│
├── server/                 ← 服务器运行时代码(Node.js 跑这部分)
│   ├── app/                ← 编译后的页面 / layout / route handler
│   │   ├── page.js              ← 首页的服务端代码
│   │   ├── note/[id]/page.js    ← 详情页
│   │   └── ...
│   ├── chunks/             ← 服务端共享代码块
│   └── middleware-manifest.json
│
├── static/                 ← 给浏览器的静态资源(带哈希文件名)
│   ├── chunks/             ← JS 代码块(按需加载、共享提取)
│   ├── css/                ← 编译压缩后的 CSS
│   ├── media/              ← 字体、图片等
│   └── <BUILD_ID>/         ← 该次构建专属的 _buildManifest.js / _ssgManifest.js
│
├── cache/                  ← 增量构建缓存(下次 build 会复用,加速)
│
└── standalone/             ← ⭐ 本项目特有(next.config.ts 开了 output: 'standalone')
    ├── server.js                ← 自带的 Node.js 启动入口
    ├── node_modules/            ← 只包含运行时实际用到的依赖(瘦身后的)
    ├── package.json
    ├── .next/
    │   ├── server/              ← 同上面的 server/
    │   └── ...
    └── (源码不会被包进来,但 standalone 会包含必要的运行时文件)

你需要分清的三类东西

类别放哪谁来读部署时是否必须
服务端代码.next/server/Node.js 进程(next start✅ 必须
浏览器静态资源.next/static/浏览器(通过 /_next/static/... URL)✅ 必须
构建缓存.next/cache/下次 next build 自身❌ 部署时不需要,但留着能加速下次构建

最小构建 output: 'standalone' 有什么用

项目 next.config.ts 写:

ts
output: 'standalone'

它会在 .next/standalone/ 里再生成一份只装必要依赖的运行时——把整个项目能跑起来的最小集合(编译后的 server 代码 + 必要的 node_modules)打包到一处。

部署的时候,只需要把这三样东西复制到服务器即可:

vbnet
.next/standalone/    ← 服务端运行时 + 精简的 node_modules
.next/static/        ← 拷贝到 .next/standalone/.next/static/
public/              ← 拷贝到 .next/standalone/public/

然后在 .next/standalone/ 里跑:

bash
node server.js

就启动了。不需要 next CLI、不需要完整的 node_modules,镜像可以瘦到 100–200 MB。

前台 SSR + 后台 SPA-like

ini
app/                                      # Next.js App Router 根目录,负责路由、布局、API 等
├── layout.tsx                            # 全站根布局,只放 html、body、全局基础结构,不能引入后台资源
├── globals.css                           # 全局基础样式,只放 Tailwind、reset、字体变量等通用样式

├── (site)/                               # 前台路由组,括号目录不会出现在 URL 中
│   ├── layout.tsx                        # 前台专属布局,例如前台导航栏、页脚等
│   ├── page.tsx                          # 前台首页,对应访问路径 /
│   └── posts/                            # 前台文章相关路由
│       └── [slug]/                       # 动态文章详情路由,例如 /posts/hello-next
│           └── page.tsx                  # 前台文章详情页,适合 SSR / SSG / ISR

├── admin/                                # 后台管理系统路由,对应 URL 前缀 /admin
│   ├── layout.tsx                        # 后台专属布局,只作用于 /admin/** 路由
│   ├── AdminProviders.tsx                # 后台专属 Provider,例如 TanStack Query、权限上下文等
│   ├── admin.css                         # 后台专属样式,只在 admin/layout.tsx 中引入
│   ├── login/                            # 后台登录页路由
│   │   └── page.tsx                      # 后台登录页面,对应 /admin/login
│   ├── dashboard/                        # 后台仪表盘路由
│   │   ├── page.tsx                      # 仪表盘路由入口,保持简单,只引入客户端组件
│   │   └── DashboardClient.tsx           # 仪表盘客户端组件,负责交互和浏览器端状态
│   └── posts/                            # 后台文章管理路由
│       ├── page.tsx                      # 后台文章管理路由入口,对应 /admin/posts
│       └── PostsClient.tsx               # 后台文章列表客户端组件,使用 TanStack Query 获取数据

└── api/                                  # Next.js Route Handlers API 路由
    └── admin/                            # 后台 API 分组,只服务后台管理系统
        └── posts/                        # 后台文章 API
            └── route.ts                  # 后台文章接口,例如 GET /api/admin/posts

components/                               # 通用组件目录
└── admin/                                # 后台专属组件目录
    └── AdminShell.tsx                    # 后台整体外壳组件,例如侧边栏、顶部栏、后台主内容区域

app/layout.tsx 不要 import 任何后台内容,否则前台访问 SSR 页面时,可能会把后台资源也带进去。
前台如果有后台入口,建议 prefetch={false},避免普通用户预加载后台资源

后台 Shell:SPA-like 的核心

ts
// components/admin/AdminShell.tsx // 后台布局外壳

'use client'
// 后台 Shell 需要使用客户端状态,所以声明为客户端组件

import Link from 'next/link'
// 引入 Link,用于后台内部客户端导航

import { usePathname } from 'next/navigation'
// 获取当前路径,用于菜单高亮

import { useState } from 'react'
// 引入 useState,用于模拟后台布局状态保持

export default function AdminShell({
  children,
}: {
  children: React.ReactNode
}) {
  const pathname = usePathname()
  // 获取当前后台路由路径

  const [collapsed, setCollapsed] = useState(false)
  // 模拟后台侧边栏折叠状态,用于证明 layout 切换页面时状态能保留

  const menus = [
    { href: '/admin/dashboard', label: '仪表盘' },
    { href: '/admin/posts', label: '文章管理' },
  ]
  // 后台菜单配置

  return (
    <div className="flex min-h-screen">
      <aside className="w-64 border-r p-4">
        <div className="mb-4 flex items-center justify-between">
          <strong>后台</strong>

          <button
            className="rounded border px-2 py-1 text-sm"
            onClick={() => setCollapsed((value) => !value)}
          >
            {collapsed ? '展开' : '折叠'}
          </button>
        </div>

        {!collapsed && (
          <nav className="space-y-2">
            {menus.map((item) => {
              const active = pathname === item.href

              return (
                <Link
                  key={item.href}
                  href={item.href}
                  className={active ? 'block font-bold' : 'block text-gray-600'}
                >
                  {item.label}
                </Link>
              )
            })}
          </nav>
        )}
      </aside>

      <section className="flex-1">
        <header className="border-b p-4">
          <span className="text-sm text-gray-500">当前路径:{pathname}</span>
        </header>

        <main className="p-6">{children}</main>
      </section>
    </div>
  )
}
// 后台页面切换时,AdminShell 作为共享 layout 的内部组件会保持,体验接近 SPA

服务器部署 + Docker 化运行

1.编辑next.config.ts

yaml
const nextConfig: NextConfig = {
  // 启用 standalone 输出,Docker 运行时只需要较少文件和依赖
  output: 'standalone',
  images: {
  	unoptimized: true,
  },
}

容器重新构建时要删除
sudo rm -rf .next

  1. 编辑 .dockerignore
bash
# .dockerignore // Docker 构建时忽略这些文件,减少镜像上下文体积

node_modules
# 本地依赖不复制进镜像

.next
# 本地构建产物不复制进镜像

.git
# Git 历史不复制进镜像

.env
.env.local
.env.development
# 本地环境变量不复制进镜像,生产环境通过 docker compose 注入

Dockerfile
docker-compose.yml
# 部署配置文件通常不需要复制到镜像内部

README.md
# 文档不复制进镜像

docker部署配置

Dockerfile文件

docker
FROM node:22-alpine
WORKDIR /app

# tini 当 PID 1,让 SIGTERM 能正常传到 node;
# openssl + libc6-compat 是 Prisma engine 在 Alpine 上的运行依赖。
RUN apk add --no-cache libc6-compat openssl tini \
 && corepack enable \
 && corepack prepare pnpm@latest --activate

# 只复制依赖清单 + prisma schema —— 让 lockfile/schema 不变时这一层完全走缓存
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml prisma.config.ts ./
COPY prisma ./prisma

# 设置国内下载源,国外服务器可移除此步骤
# 装依赖;postinstall 会跑 prisma generate(pnpm install 会自动触发)。
# `--frozen-lockfile` 强制按 pnpm-lock.yaml 装,避免和本地不一致。
RUN pnpm config set registry https://registry.npmmirror.com \
 && pnpm config set fetch-retries 5 \
 && pnpm config set fetch-timeout 600000 \
 && pnpm install --frozen-lockfile --network-concurrency=4

ENV NODE_ENV=production \
    NEXT_TELEMETRY_DISABLED=1 \
    PORT=3000 \
    HOSTNAME=0.0.0.0

EXPOSE 3000

# 容器启动逻辑:
#   1. 没有可运行的 standalone server 就跑 pnpm build —— 首次启动 / 旧构建残留都会落到这里
#   2. 已经有 .next/standalone/server.js 就直接复用,秒起
#   3. Caddy 直接服务 /_next/static/* 和 /static/*,Next 只处理动态请求
#   4. exec node .next/standalone/server.js —— standalone 模式的正确启动方式
#
# 改代码后想看到新版:在宿主机 rm -rf .next && docker compose restart app
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["sh", "-c", "set -e; \
  if [ ! -f .next/standalone/server.js ]; then \
    echo '>> .next 不存在,开始构建 (pnpm build)…'; \
    rm -rf .next; \
    pnpm build; \
  else \
    echo '>> 复用已有 .next/standalone/server.js(如需重建:宿主机 rm -rf .next 后 restart)'; \
  fi; \
  echo '>> 启动 Next.js standalone server(监听 0.0.0.0:3000)'; \
  exec node .next/standalone/server.js"]

docker-compose.yml文件

yml
# 部署模式:镜像固化依赖 + 宿主机 bind mount 源码
# 工作流:
#   - 首次 / 依赖变更:docker compose up -d --build       (会 pnpm install)
#   - 改代码后:       rm -rf .next && docker compose restart app  (会 pnpm build)
#   - 只是重启:       docker compose restart app         (秒起,复用 .next)
#
# 关键技巧:node_modules 用一个命名卷挂在 /app/node_modules 上,把 bind mount
# 在那个子目录的覆盖"顶回去",露出镜像里 pnpm install 装好的那份。
# 不加这个命名卷,bind mount 会把容器里的 node_modules 替换成宿主机的版本
# (可能不存在 / 是 Windows 架构 / 缺 prisma engine),容器立刻起不来。
#
# PostgreSQL 不在 compose 里。常见三种来源都支持:
#   - 宿主机原生 PG:.env 里 DATABASE_URL 用 host.docker.internal:5432
#   - 同台机另一个 docker 容器:放进同一 network,或继续走 host.docker.internal
#   - 外部托管:.env 里 DATABASE_URL 直接写公网串
#
# 环境变量:.env 仍是单一事实源,同时通过 env_file 注入容器运行环境。
# 注意 standalone 的 `node .next/standalone/server.js` 运行期不应依赖 Next
# 自动读取 /app/.env;显式 env_file 能保证 Server Action / Prisma runtime
# 读到最新的 process.env。

name: blog

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: blog:local
    container_name: blog
    working_dir: /app
    restart: unless-stopped
    env_file:
      - .env

    volumes:
      # 整个仓库 bind mount 进容器 —— 源码 / .env / .next / public 全部同步
      - .:/app
      # 把 /app/node_modules 用命名卷"罩住",让镜像里 pnpm install 出来的
      # 那份 node_modules 露出来(不被上面这条 bind mount 覆盖)。
      # 依赖变了重建镜像时务必 docker compose down -v 清掉这个卷再 up,
      # 否则容器还在用旧 volume 里的旧 node_modules。
      - node_modules:/app/node_modules

    # 让容器能用 host.docker.internal 解析到宿主机 IP(Linux 上要显式声明)。
    # 如果 PostgreSQL 跑在宿主机上,.env 里 DATABASE_URL 写
    #   postgresql://user:pwd@host.docker.internal:5432/blog?schema=public
    extra_hosts:
      - "host.docker.internal:host-gateway"

    # 只把 3000 绑到宿主机 127.0.0.1,由宿主机的 nginx 反代到 80/443
    ports:
      - "127.0.0.1:3000:3000"

    # 加入数据库那套 compose 的外部网络,通过容器名 db / redis 直连
    networks:
      - backend

    healthcheck:
      # next start 默认会响应 GET /,用 wget 比 curl 在 alpine 上更稳
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/ >/dev/null || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 5
      # 首次启动要跑 next build,留宽 start_period 防止误判 unhealthy
      start_period: 180s

networks:
  # 引用数据库 compose 创建的外部网络;启动本项目前确保它已存在
  # (在数据库那侧先 docker compose up -d,或 docker network create backend)
  backend:
    external: true
    name: backend

volumes:
  # 命名卷:存镜像里 pnpm install 出来的 node_modules
  # 完整名是 `blog_node_modules`(compose 项目名 blog + 卷名 node_modules)
  node_modules:

nginx配置示范

conf
# /etc/nginx/sites-available/blog.conf
# 缓存策略:
#   /_next/static/* —— Next 自己生成的 hash 命名资源,immutable + 1 年
#   /static/*       —— 项目自有静态文件(favicon、占位图),immutable + 1 年
#   其余路径        —— 不让 nginx 缓存,Next 自己用 Cache-Control / ISR 头说了算
#
# nginx 坑提示(已在下方处理):
#   1. 每个 location 一旦写了自己的 add_header,server 级的 add_header **全部失效**。
#      这里把通用安全头抽成一段,每个 location 用 `include` 引用(也可以原样重复粘贴)。
#   2. WebSocket 的 `Connection: upgrade` 不能写死,要用 map 看请求实际是不是 WS。

# ── WebSocket upgrade 协商(http 块级)──
# 这一段如果你的 nginx 已经在 /etc/nginx/conf.d/ 下定义过同名 map,把它注释掉。
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# ── HTTP → HTTPS 跳转 ──
server {
    listen 80;
    listen [::]:80;
    server_name your-domain.com www.your-domain.com;

    # 给 certbot 续期留口子
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# ── HTTPS 主站 ──
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name your-domain.com www.your-domain.com;

    # ── TLS(certbot 自动维护) ──
    ssl_certificate     /etc/letsencrypt/live/your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;
    ssl_session_tickets off;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    # ── 通用安全头 ──
    # `always` 让非 2xx/3xx 响应也带;下方有 add_header 的 location 内部都重复了一遍,
    # 防止被 nginx 的 add_header 继承规则吞掉。
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Frame-Options            "SAMEORIGIN"                          always;
    add_header X-Content-Type-Options     "nosniff"                             always;
    add_header Referrer-Policy            "strict-origin-when-cross-origin"     always;

    # 上游写成变量便于改端口
    set $upstream http://127.0.0.1:3000;

    # ── 压缩 ──
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_min_length 1024;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        application/rss+xml
        image/svg+xml;

    # 编辑器贴大段 markdown 时给点余量
    client_max_body_size 10m;

    # ── /_next/static/ —— Next 自己产出,hash 命名,直接由 nginx 读取,1 年 immutable ──
    location /_next/static/ {
        alias /home/ubuntu/blog/.next/static/;
        access_log off;

        expires 365d;
        add_header Cache-Control "public, max-age=31536000, immutable" always;

        # 重复一遍 server 级的安全头(被本块的 add_header 顶掉了)
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
        add_header X-Content-Type-Options     "nosniff"                             always;
    }

    # ── /static/ —— 项目自有静态文件(public/static/ 下),直接由 nginx 读取,1 年 immutable ──
    # 文件名稳定且自有,更新时手动改文件名或带 ?v= 参数即可破缓存。
    location ^~ /static/ {
        alias /home/ubuntu/blog/public/static/;
        access_log off;

        expires 365d;
        add_header Cache-Control "public, max-age=31536000, immutable" always;

        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
        add_header X-Content-Type-Options     "nosniff"                             always;
    }

    # ── 其它一切 —— Next.js ──
    location / {
        proxy_pass $upstream;
        proxy_http_version 1.1;

        # WebSocket / RSC stream / Server Actions
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        $connection_upgrade;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Port  $server_port;

        # RSC 是流式响应,关掉缓冲让首字节更快
        proxy_buffering off;
        # Server Actions 也是流式(FormData / multipart 大 body 时也别缓冲请求)
        proxy_request_buffering off;

        # Server Actions / 大 markdown 提交可能慢,放宽超时
        proxy_connect_timeout 30s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;
    }
}

每次修改代码后

sudo rm -rf .next # 删除
sudo docker compose up -d --build --force-recreate #无缓存重新构建