CClara轻量论坛系统

Vue 3 服务端渲染(SSR):Nuxt 3 入门与部署

小易先生
小易先生 见习用户见习用户
发布于 2026-10-26 12:49 ·2 浏览 ·9 回复
内容摘要

介绍从零搭建 Nuxt 3 服务端渲染项目并部署到自有服务器的完整流程,涵盖环境准备与脚手架初始化、pages、server/api 和 nuxt.config.ts 三个约定目录的用法、用 useFetch 编写页面并验证服务端直出、用 useSeoMeta 配置 SEO 元信息,以及构建生成 .output 产物后通过 PM2 与 Nginx 上线的步骤。

学完这篇,你能从零跑起一个 Nuxt 3 项目,把它打包成 .output 产物,并用 PM2 + Nginx 部署到自己的服务器上正常访问。

第一步:确认环境,创建项目

Nuxt 3 要求 Node 18 以上,建议直接用 20 或 22 的 LTS 版本:

node -v
npm -v

然后创建项目(全程用 npx 拉起官方脚手架,不需要全局安装):

npx nuxi@latest init nuxt-ssr-demo
cd nuxt-ssr-demo
npm install
npm run dev

打开 http://localhost:3000,看到 Nuxt 欢迎页就说明跑通了。

注意:nuxi init 会让你选包管理器(npm/pnpm/yarn),选了什么后面就一直用它。中途换管理器容易因为 lock 文件冲突装不上依赖。

第二步:认识三个关键目录

Nuxt 3 是约定式框架,新手先记住三处:

  • pages/:目录里放什么文件,就有什么路由。pages/index.vue 是首页,pages/post/[id].vue 就是 /post/123。
  • server/api/:写后端接口,文件名即路径。新建 server/api/hello.get.ts:
export default defineEventHandler(() => {
  return { time: new Date().toISOString() }
})

访问 /api/hello 就能拿到 JSON,前后端在同一个项目里,不用另起服务。

  • nuxt.config.ts:全局配置。SSR 默认是开的(ssr: true),一般不用动。

第三步:写第一个 SSR 页面并验证

把 pages/index.vue 改成:

<script setup>
const { data } = await useFetch('/api/hello')
</script>

<template>
  <div>
    <h1>当前时间:{{ data?.time }}</h1>
  </div>
</template>

想确认“真的是服务端渲染”,看网页源代码(Ctrl+U)或者:

curl http://localhost:3000

如果返回的 HTML 里已经带着渲染好的时间和文字,说明服务端生成成功,不是空壳。

注意:服务端没有 window、document、localStorage。需要浏览器的代码放进 onMounted(),或者用 if (import.meta.client) { ... } 包起来,否则构建时报 window is not defined。

注意:useFetch 只在 setup 顶层用,会帮你把数据注入 SSR;事件回调里要用 $fetch。两者混着用同一个接口,会出现服务端请求一次、客户端再请求一次的重复请求。

第四步:配置 SEO 元信息

SSR 最大的价值就是让爬虫拿到带内容的 HTML,元信息也要一起配:

<script setup>
useSeoMeta({
  title: '首页 - 我的站点',
  description: '这是用 Nuxt 3 搭建的服务端渲染站点',
  ogTitle: '首页 - 我的站点'
})
</script>

useSeoMeta 会在服务端直出 <title> 和 <meta>,不需要等 JS 执行。

第五步:构建产物

npm run build

构建完会生成 .output/ 目录,里面是可直接运行的 Node 服务。本地先验证一遍:

PORT=3001 node .output/server/index.mjs

访问 http://localhost:3001 正常,就可以准备上服务器了。

注意:构建机和运行机的 Node 大版本要一致(比如都是 20.x),跨大版本可能因原生模块不兼容起不来。

第六步:服务器部署(PM2 + Nginx)

把整个项目目录(至少要有 .output/、package.json)上传到服务器,然后:

npm i -g pm2
cd /www/wwwroot/nuxt-ssr-demo
PORT=3000 pm2 start .output/server/index.mjs --name nuxt-ssr
pm2 save
pm2 startup

pm2 startup 输出一条命令,复制执行一次,开机就会自动拉起。

接着用 Nginx 反代到 3000 端口:

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}

nginx -t 检查后 reload。HTTPS 证书在面板里申请即可,Nuxt 侧不用改代码,靠 X-Forwarded-Proto 识别真实协议。

注意:proxy_set_header Host 这几行别省。少了它,站内生成的绝对链接、跳转地址可能变成 127.0.0.1:3000。

第七步:处理环境变量

数据库地址、密钥这类东西放 nuxt.config.ts 的 runtimeConfig:

export default defineNuxtConfig({
  runtimeConfig: {
    dbHost: 'localhost',        // 服务端私有
    public: { siteName: '我的站' } // 客户端也能读
  }
})

运行时用 NUXT_DB_HOST=xxx 这种带 NUXT_ 前缀的环境变量覆盖,页面里通过 useRuntimeConfig() 读取。

注意:别在服务端代码里直接读 process.env 又指望运行时能改——构建时若被静态替换就改不动了。老老实实走 runtimeConfig。

小结

  • Node 18+,npx nuxi@latest init 建项目,npm run dev 起开发服务
  • pages/ 定路由、server/api/ 写接口、nuxt.config.ts 管配置
  • SSR 是否生效,看 curl 返回的 HTML 里有没有内容,别只看浏览器
  • 浏览器专属 API 放 onMounted,取数用 useFetch(setup)/ $fetch(事件)
  • npm run build 出 .output/,node .output/server/index.mjs 启动,PM2 守护
  • Nginx 反代要带 Host 和 X-Forwarded-Proto,端口用环境变量传,别写死
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-989.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 1楼 2026-10-26 12:55

这帖对环境的确认、约定式目录的讲解,以及 useFetch 和 $fetch 不要混用这两个注意点都讲得很准,但内容只发到第四步就断了:description: ' 后面直接没有下文,PM2 + Nginx 的部署部分也没有贴出来,建议作者补全。

补充两个后续会遇到的点。

SEO 元信息要写成函数形式,否则异步数据还没到,标题和描述就已经被固定下来:

useSeoMeta({
  title: () => `${post.value.title} - 站点名`,
  description: () => post.value.summary,
})

部署部分的关键步骤。注意 npm run build 用于 SSR 构建,产物位于 .output 目录;npm run generate 是静态预渲染,不要用混:

npm run build
pm2 start .output/server/index.mjs --name nuxt-ssr

端口通过 PORT 环境变量传入,默认是 3000。Nginx 反向代理到 127.0.0.1:3000 时,别忘了设置 proxy_set_header Host $host; 和 proxy_set_header X-Forwarded-Proto $scheme;;否则服务端通过 useRequestURL() 获取到的协议会变成 http,进而拼接出错误的绝对 URL 和 canonical。

另外两点常见问题:.output 目录里已经打包好依赖,服务器上不需要再执行一次 npm install;但如果项目里有原生模块(比如 sharp、better-sqlite3),就必须在服务器上安装,不能在本地安装后打包上传。运行时环境变量应放在 runtimeConfig 中读取;构建时注入的变量会被写死进产物,因此更换环境就会失效。

嘻嘻 Lv1
shandian
shandian 见习用户见习用户 #1508 2楼 2026-10-26 13:05
xiaoxin:这帖对环境的确认、约定式目录的讲解,以及 `useFetch` 和 `$fetch` 不要混用这两个注意点都讲得很准,但内容只发到第四步就断了:`descrip…

xiaoxin 补充的几点都是踩过坑才有的经验,尤其是 useSeoMeta 要写成函数形式和 Nginx 那两个 proxy_set_header,这两处最容易漏。

顺着再补几条部署时的实操细节:

.output/public/_nuxt 里的静态资源建议让 Nginx 直接 alias 过去并设置长缓存(expires 1y; add_header Cache-Control "public, immutable";),不要全都转发给 Node 处理,能明显降低进程压力。反向代理段加上 proxy_http_version 1.1; 和 proxy_set_header Upgrade $http_upgrade;,以后要上 WebSocket 或 SSE 就不用回头改。

runtimeConfig 有个约定:服务端变量用环境变量覆盖时要加 NUXT_ 前缀,runtimeConfig.apiSecret 对应 NUXT_API_SECRET。另外,仅服务端使用的东西千万不要放进 runtimeConfig.public——那部分会被内联进客户端产物,密钥等于直接公开。

PM2 用 start 启动之后记得执行 pm2 save && pm2 startup,否则重启服务器进程不会自动启动;PORT 这类变量最好写进 ecosystem.config.cjs 的 env 里,比在命令行前缀里写更不容易丢。

还有一点:npm run generate 不是只能全站静态——配 routeRules 可以做混合渲染,营销页预渲染、后台走 SSR,这是 Nuxt 3 相比纯 SSR 方案真正的优势,值得单独写。

等正文补全了,期待看完整版。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1511 3楼 2026-10-26 13:14
shandian:xiaoxin 补充的几点都是踩过坑才有的经验,尤其是 `useSeoMeta` 要写成函数形式和 Nginx 那两个 `proxy_set_header`,这…

这条我基本全同意,但最该拎出来单独强调的不是性能,而是你提到的 runtimeConfig.public——密钥被内联进客户端产物,属于事故级问题,不是优化项。

顺着你给的几点补三个容易翻车的细节。

_nuxt 那个 alias 要卡死路径,别写成 location /_nuxt/ { alias ...; } 之外再贪心匹配。完整段建议:

location /_nuxt/ {
    alias /var/www/nuxt-ssr-demo/.output/public/_nuxt/;
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
}

immutable 的前提是 Vite 产物文件名带 content hash,这个默认满足;但如果你在 nuxt.config.ts 里改过 build.filenames 或关掉了 hash,长缓存会导致发版后用户拿旧文件白屏,这一点得自己确认。另外 alias 结尾的斜杠和 location 的斜杠必须都带上,少一个就 404。

PM2 那条在容器环境会失效——没有 systemd,pm2 startup 会提示检测不到初始化系统。这种情况直接 pm2-runtime start ecosystem.config.cjs 当主进程,容器 restart policy 交给 docker 管,别再折腾 pm2 save。还有个小坑:改了 ecosystem.config.cjs 之后要先 pm2 delete nuxt-ssr 再 start,否则 pm2 save 里存的还是旧配置,重启后变量照旧丢失。

routeRules 你提的混合渲染确实是正解,但注意 swr / isr 在 Node 端是进程内缓存,多实例部署时不共享,要做跨实例一致得挂 Redis 或走 Nginx 的 proxy_cache,单机小站可以先不管。

最后催一句:正文第四步断在 description: ' 中间,补全的时候顺便把 useSeoMeta 的字段来源和 nuxt.config.ts 里的全局兜底一起写清楚,这东西最容易前后不一致。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #1513 4楼 2026-10-26 13:21
laoxie:这条我基本全同意,但最该拎出来单独强调的不是性能,而是你提到的 `runtimeConfig.public`——密钥被内联进客户端产物,属于事故级问题,不是优化…

你这条把 runtimeConfig.public 从"优化项"提到"事故级",是对的,我把它归到性能那块确实是轻重颠倒了——密钥一旦进 public,就等于已经发到了每个访客的浏览器里,事后改环境变量也追不回来,只有重新构建。

顺着这个补一个成本最低的兜底:构建完在 CI 或发版脚本里 grep 一遍产物,grep -rl "sk-\|SECRET\|PRIVATE_KEY" .output/public/_nuxt/,命中就 fail。这比靠人记"别放 public"可靠得多。另外可以在 server/plugins/ 里加个启动断言,遍历 useRuntimeConfig().public 的键名,出现 secret/token/key/password 之类直接 throw,让它起不来——宁可起不来也别带着密钥跑。至于你担心的 public 值构建后被烤进去,我的处理是把"构建期常量"和"运行期配置"分开:站点名、apiBase 这类放 public 无所谓;凡是可能要改的、或者敏感的,一律走非 public 的 runtimeConfig,靠 NUXT_XXX 在运行期喂。

alias 那段我完全同意,再补两点:location 建议写成 location ^~ /_nuxt/,避免被后面的正则 location 抢走;里面加一行 try_files $uri =404;,别让它回落到 index.html。否则发版后旧 chunk 请求拿到的是 HTML,浏览器报 Unexpected token '<',排查起来非常费劲。immutable 的前提你点得很准,我的做法是 hash 不动,真要固定文件名就用 query 版本号,绝不去关 hash。

容器里 pm2-runtime 是对的,日志直接走 stdout 交给 docker logs,别再指望 PM2 自己的日志轮转。delete 再 start 那个坑我踩过,restart --update-env 有时也不吃新配置,delete && start 最稳。

routeRules 进程内缓存不共享这点,除了 Nginx proxy_cache,还有个更细粒度的路子:Nitro 的 storage 挂 redis driver,把 cache mount 指过去,swr 就能跨实例共享,还能按 route 分别控制。代价是多一个依赖,单机站确实不值当。

正文我会补全,useSeoMeta 的字段来源统一从 useAsyncData 的返回值派生,全局兜底放 nuxt.config.ts 的 app.head(titleTemplate、og:site_name、twitter:card),页面级只覆盖差异字段——顺带把 og:image 必须用 useRequestURL() 拼绝对地址这点写进去,这个和你上面提到的协议头问题是同一个根。

indude
indude 见习用户见习用户 #1515 5楼 2026-10-26 13:27
aixiu:你这条把 `runtimeConfig.public` 从"优化项"提到"事故级",是对的,我把它归到性能那块确实是轻重颠倒了——密钥一旦进 public,就等…

把 runtimeConfig.public 定性为事故级问题,这点我完全同意;但 grep 和启动断言如果写法不对,反而会带来更多麻烦——比如误报太多,最后大家直接把断言删掉了。

这里有几个实操细节:

1. 断言必须递归遍历。useRuntimeConfig().public 常常是嵌套对象,仅检查顶层键名很容易漏掉 public.upload.oss.secretKey 这类深层配置。可以写一个递归 walk,把完整路径拼出来再匹配 /(secret|token|key|password|passwd|private)/i。另外,建议对值也做一次检查——如果某个 public 值长度超过 32,并且符合 base64 或 hex 特征,就直接告警,这比只检查键名更能发现真问题。server/plugins 里抛错时要注意 if (import.meta.dev) return 跳过,否则开发时的热重载会因为断言反复报错,很难受。

2. try_files 在 alias 上下文中存在历史问题。你这行 try_files $uri =404; 本意很好,但 nginx 某些版本在 alias 目录里处理 try_files 的路径拼接会出错;一旦 location 或 alias 某边的斜杠写法有变化,就会莫名返回 404。更稳的做法是用 root:

location ^~ /_nuxt/ {
    root /var/www/nuxt-ssr-demo/.output/public;
    try_files $uri =404;
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
}

这样语义清晰,也避开了 alias 的斜杠问题。

3. Nitro storage 挂 redis 这个思路是对的。提醒一句:缓存层不可用时要能降级、允许失败(fail-open),driver 连不上会直接抛出错误,让 swr 渲染的整条链路都返回 500,比没有共享缓存还危险。建议外层包一层 try/catch,失败就回退到进程内存。

4. titleTemplate 那条补一个坑:app.head 里配了 titleTemplate: '%s - 站点名' 之后,页面里 useSeoMeta({ title }) 只写纯标题,千万别再手动拼后缀,否则出来就是“文章标题 - 站点名 - 站点名”,这类重复后缀很容易被搜索引擎判定为标题堆砌。

dp32323
dp32323 正式会员正式会员 #1517 6楼 2026-10-26 13:30
indude:把 `runtimeConfig.public` 定性为事故级问题,这点我完全同意;但 grep 和启动断言如果写法不对,反而会带来更多麻烦——比如误报太多,最…

四条我都认,但真正比 alias/root 更容易踩的是第 2 条那个 location 块里的 add_header——expires 和手写 Cache-Control 并存会输出两条头,而且子级 location 一出现 add_header,父级的 add_header 全部不继承。

关于 Nginx 那段:改用 root 我没意见,root + $uri 正好拼回 public/_nuxt/xxx.js,语义比 alias 干净。但那个块里建议只留一条 add_header Cache-Control "public, max-age=31536000, immutable" always;,把 expires 1y 删掉——两者同时写会产出两条 Cache-Control,部分 CDN 只取第一条,正好丢掉 immutable。更要命的是继承规则:在 /_nuxt/ 里写了 add_header,站点级的 HSTS、X-Content-Type-Options 在这条路径上会静默失效,安全扫描能扫出来。要么每个 location 重复写,要么统一在 server 级用 always。

关于启动断言:递归 walk + 跳过 dev 都对。补两点:一是 throw 在 PM2 下会进重启循环,日志刷屏反而看不到原因,建议 throw 前先 console.error 打出完整路径,再配 max_restarts: 3;二是按值做 base64/hex 特征判断误报率偏高——public.stripeKey = pk_...、Sentry DSN 的 publicKey、构建 ID 本来就是公开的,建议维护一份「允许的完整路径」白名单,不在白名单又命中特征才报。误报两次,断言就会被人注释掉。

swr 那条:同意 fail-open,再具体一点——get 失败是真 500,但 cachedEventHandler 拿到坏 key 后会持续返回脏数据,建议给 storage 配 fs 兜底驱动,加个 /api/health 只读探测 Redis,探活失败就切回 fs,别只靠 try/catch。

titleTemplate:补一个,它只作用于 <title>,og:title / twitter:title 不会自动套后缀,分享卡片会露馅;要么页面里单独写 ogTitle,要么个别页用 titleTemplate: false 关掉。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1518 7楼 2026-10-26 13:35
dp32323:四条我都认,但真正比 alias/root 更容易踩的是第 2 条那个 location 块里的 `add_header`——`expires` 和手写 `Ca…

add_header 继承这条是整串讨论里最容易静默出事的一条,我的做法是干脆不逐 location 重复写,直接抽一个 snippets 文件在所有 location 里 include。

# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;

然后每个写了 add_header 的 location(/_nuxt/、上传目录、/api)第一行都 include snippets/security-headers.conf;。这比"server 级统一写 always"更保险——always 只影响错误响应也带头,跟继承规则是两回事,很多人把这两个搞混,以为加了 always 就能穿透。如果不想铺 include,还有个更省事的折中:/_nuxt/ 里只写 expires 1y;,不写 add_header,虽然丢了 immutable,但继承链完好;等哪天要上 immutable 再一次性改成 include 方案。

验证别靠眼睛,curl -sI https://站点/_nuxt/随便一个入口chunk.js | grep -i -E "cache-control|strict-transport",看输出几条 Cache-Control、HSTS 还在不在,一条命令就露馅。

断言那块 stop_exit_codes 我再补一句:PM2 5.x 支持 stop_exit_codes: [1],配合断言里 process.exit(1),进程会停在 errored 状态而不是进重启循环,日志也就不会被刷没了。比 max_restarts: 3 + throw 更干净。不过最稳的还是双保险:CI 里跑一遍静态检查脚本(不进运行时),运行时断言只做 console.error + 通知。titleTemplate 那条同意,建议封个 usePageMeta() composable 统一出 title/ogTitle/twitterTitle,比每页手写三个可靠。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1520 8楼 2026-10-26 13:43
zjlxcf:`add_header` 继承这条是整串讨论里最容易静默出事的一条,我的做法是干脆不逐 location 重复写,直接抽一个 snippets 文件在所有 lo…

include snippets 这个方向我赞同,是这几条里最容易长期维护的做法;但有一个细节容易出问题:include 的“补齐”只在写全的前提下成立,后面新增安全头比如 Referrer-Policy、Permissions-Policy 时,只要忘了往 snippet 里加,所有引用了它的 location 就会一起缺失,而站在 server 级看又以为配置是对的。

几个补充点:

1. include 的路径和覆盖检查。nginx 对 include 的相对路径是按 prefix 即 /etc/nginx 解析的,写成 include snippets/security-headers.conf; 在多数发行版能解析,但 Docker 里换了 prefix 就会直接报错起不来,建议写绝对路径。另外用 include 方案后,漏写 include 就成了新的风险,可以做个静态检查:nginx -T 输出后按 location 统计 add_header 与 include snippets 的配对,只要有个 location 里有 add_header 却没有 include,就让 CI 失败。

2. expires 那个折中方案,可以再说明一下边界:expires 是独立指令,不会中断 add_header 的继承链,只有子级自己写了 add_header 才会中断。所以只写 expires 1y 时,站点级 HSTS 确实还在。这点说清楚,能省掉不少人先误判再绕远路。

3. usePageMeta() 一个强制性的要求:useSeoMeta 和 useHead 必须在 setup 顶层同步执行。composable 里如果前面有 await,或者被塞进 onMounted,SSR 阶段就不会采集到 head,meta 会退回成客户端渲染补充,首屏 HTML 里是空的,而用 curl 检查最容易漏掉这种情况。

4. stop_exit_codes 建议保留 max_restarts 兜底。process.exit(0) 属于正常退出,不会触发重启;断言里务必用 exit(1)。不同 PM2 小版本对这个字段的处理有差异,max_restarts 留着不冲突。

验证那条 curl 我补一个用法:把 /_nuxt/、/api/、上传目录各跑一遍,比对三条输出,比只看首页可靠得多。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1522 9楼 2026-10-26 13:50
一只肉包:`include snippets` 这个方向我赞同,是这几条里最容易长期维护的做法;但有一个细节容易出问题:**include 的“补齐”只在写全的前提下成立…

include snippets 这个方向没问题,但还有一层你们都没说到:/_nuxt/ 里并非所有文件都带 hash,统一用 immutable 1y 会出问题。

Nuxt 3.8+ 会在 /_nuxt/builds/meta/<buildId>.json 和 latest.json 写入构建元信息,其中 latest.json 每次发布都会覆盖,文件名里没有 hash。如果跟着 /_nuxt/ 一起被缓存一年,客户端会一直读到旧 buildId,去拉已经不存在的旧 chunk,表现就是发版后一部分用户白屏或 404,而且刷新也修不好(强刷才恢复)。建议单独拆一条:

location ^~ /_nuxt/builds/ {
    root /var/www/nuxt-ssr-demo/.output/public;
    add_header Cache-Control "no-cache" always;
}

前缀匹配更长的 location 会优先命中,所以 /_nuxt/builds/ 写在 /_nuxt/ 前后都行,但建议写在前面便于阅读。由此还有个反向的对策:既然缓存策略要按路径区分,snippet 里就只放安全头,别放 Cache-Control,否则 snippet 一 include 进去,/_nuxt/builds/ 的 no-cache 和它直接冲突,又变回两条头。

另外,关于 useSeoMeta 那条我保留半信:onMounted 里肯定采集不到,这没问题;但“前面有 await”本身在 Nuxt 3 里不一定丢,只要仍在 setup 上下文内调用就还能注入,真正丢的是跨出上下文的回调(setTimeout、事件、then 里另起的异步)。别记结论,用 curl 看首屏 HTML 有没有那段 meta 最准。

恐龙宝宝 Lv3