pnpm dev 调试运行
Remove-Item -Recurse -Force .next 清理css缓存
pnpm build 构建
pnpm start 构建后运行
Next.js 是什么
一个 React 框架。在 React 之上多帮你做了三件事:
- 路由:你在
app/下建一个文件夹,URL 就自动出来了,不用配 react-router。 - 服务端渲染(SSR / SSG):默认在服务器上把页面渲染成 HTML 再发给浏览器,对 SEO 和首屏速度友好。
- 打包优化:内置 Webpack / Turbopack,分代码、压缩、图片优化、字体优化这些不用手动配。
项目目录结构
最小结构
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.tsx | 404 页面 |
[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 好。
只有需要交互(onClick、useState、useEffect)的组件才在文件顶部加:
"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"目录
.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 写:
output: 'standalone'
它会在 .next/standalone/ 里再生成一份只装必要依赖的运行时——把整个项目能跑起来的最小集合(编译后的 server 代码 + 必要的 node_modules)打包到一处。
部署的时候,只需要把这三样东西复制到服务器即可:
.next/standalone/ ← 服务端运行时 + 精简的 node_modules
.next/static/ ← 拷贝到 .next/standalone/.next/static/
public/ ← 拷贝到 .next/standalone/public/
然后在 .next/standalone/ 里跑:
node server.js
就启动了。不需要 next CLI、不需要完整的 node_modules,镜像可以瘦到 100–200 MB。
前台 SSR + 后台 SPA-like
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 的核心
// 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
const nextConfig: NextConfig = {
// 启用 standalone 输出,Docker 运行时只需要较少文件和依赖
output: 'standalone',
images: {
unoptimized: true,
},
}
容器重新构建时要删除
sudo rm -rf .next
- 编辑 .dockerignore
# .dockerignore // Docker 构建时忽略这些文件,减少镜像上下文体积
node_modules
# 本地依赖不复制进镜像
.next
# 本地构建产物不复制进镜像
.git
# Git 历史不复制进镜像
.env
.env.local
.env.development
# 本地环境变量不复制进镜像,生产环境通过 docker compose 注入
Dockerfile
docker-compose.yml
# 部署配置文件通常不需要复制到镜像内部
README.md
# 文档不复制进镜像
docker部署配置
Dockerfile文件
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文件
# 部署模式:镜像固化依赖 + 宿主机 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配置示范
# /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 #无缓存重新构建