SEO
Swift Web 框架將 SEO 作為強制啟用、不可關閉的核心能力,整體由
@nuxtjs/sitemap + nuxt-schema-org + @nuxtjs/robots + 框架自帶 useSeo() composable 協作完成。
設計哲學:SEO 是 Nuxt 專案存在的核心理由(不需要 SEO 用 Vue3 SPA 即可), 框架本不該給「關閉 SEO」的開關。所有 swift-web 專案自動獲得 sitemap.xml + robots.txt + JSON-LD 全套能力。
useSeo():一站式 SEO 元資訊輸出
每個 app/pages/**/*.vue 必須在 <script setup> 頂層呼叫 useSeo()(ESLint
swift-web/require-useSeo-in-pages 規則強制,severity warn;CI 可 --max-warnings=0 升級硬阻斷;配套 swift-web/seo-tdk-length 對字面量 title/description 做長度靜態預檢——description 顯示寬度 <50 / >200、title >70 當場 warn),框架自動輸出全套 SEO 元標籤:
| 標籤 | 來源 |
|---|---|
<title> <meta name="description"> <meta name="keywords"> | useSeo input |
<meta property="og:title"> og:description og:image og:url og:type og:locale og:site_name | useSeo input + 自動兜底 |
<meta property="og:image:width"> og:image:height | 1200 / 630(OG 標準) |
<meta name="twitter:card"> twitter:title twitter:description twitter:image | summary_large_image + 與 og 同步 |
<link rel="canonical"> | input.canonicalPath ?? requestURL.pathname |
<script type="application/ld+json"> | WebSite + WebPage(schemaOrg !== false 時) |
<meta name="robots"> | input.robots(可選) |
基本用法
<script setup lang="ts">
useSeo({
title: '頁面標題',
description: '頁面描述(建議 50-160 字元)',
})
</script>
詳情頁對接後端 SEO 節點
後端約定詳情類 API 回傳 seo 節點({ title, description, image }),業務側用
?? data 兜底鏈:
<script setup lang="ts">
const { data } = await useFetch(`/movie/detail/${id}`)
useSeo(() => ({
title: data.value?.seo?.title ?? data.value?.name,
description: data.value?.seo?.description ?? data.value?.summary,
image: data.value?.seo?.image ?? data.value?.cover,
type: 'article',
article: {
author: data.value?.author,
datePublished: data.value?.created_at,
},
}))
</script>
<title> 模板(titleTemplate)
交還 Nuxt 原生,framework 不注入
framework 不接管 titleTemplate(framework#89)。<title> 的站點名後綴由 applier 在 nuxt.config.ts 的 app.head.titleTemplate 用 Nuxt 原生機制配置,站點名來自 swift.config.json 的 project.title(單一事實源):
// nuxt.config.ts
import swiftConfig from './swift.config.json'
export default defineNuxtConfig({
app: {
head: {
titleTemplate: `%s | ${swiftConfig.project.title}`,
},
},
})
// 頁面
useSeo({ title: '影城會員管理', description: '...' })
// → <title>影城會員管理 | 影城管理系統</title>
自訂分隔符 / 品牌前綴 —— 直接調整 titleTemplate 字串順序:
titleTemplate: `${swiftConfig.project.title} - %s` // 品牌前綴
titleTemplate: '%s · 影城管理系統' // 寫死品牌
titleTemplate: '%s' // 無品牌後綴
多語言站點名 —— Nuxt 原生 titleTemplate 是建置期靜態字串,不支援 t()。需要按語種翻譯站點名時把 titleTemplate 設為 '%s',在頁面用 useSeo({ title: \${t('頁面標題')} | ${t('站點名')}` })` 傳拼好的完整 title。
兜底行為
- 頁面沒傳
title→console.warn(<title>僅剩 titleTemplate 的站點名後綴)。所有頁面都應顯式傳 title。 - useSeo 不再從後端
site_title兜底 title,也不再注入 titleTemplate / 替換%site_title。 - JSON-LD
WebSite.name取runtimeConfig.public.siteName(applier 從swift.config.json的project.title寫入),不再讀後端site_title。
私有頁 noindex(robots prop)
登入頁、個人中心、後台等不應被搜尋引擎索引的頁面,加 robots prop:
<script setup lang="ts">
// 完全私有頁(不索引、不追連結)
useSeo({
title: '登入',
description: '登入帳號以使用完整功能',
robots: 'private', // → <meta name="robots" content="noindex, nofollow">
})
// 中轉 / 搜尋結果頁(不索引但允許追連結)
useSeo({
title: '頁面即將跳轉',
description: '安全外連跳轉中轉頁',
robots: 'noindex', // → <meta name="robots" content="noindex, follow">
})
</script>
⚠️ 不要同時在
_robots.txt加Disallow: /login/—— 那會讓爬蟲存取不到頁面,反而看不到 noindex meta,可能「無 snippet 索引」反向損害 SEO。
origin / canonical 單一事實源 + CSR 診斷
framework#87 把 useSeo origin 決策從 4 層兜底簡化為 1 個事實源 + 1 個顯式逃生口:
L1 顯式 input.siteUrl ← 單頁覆寫(跳過 CSR 診斷)
L2 NUXT_PUBLIC_SITE_URL 設了它即**收斂**到該網域(opt-in,跳過 CSR 診斷)
L3 useRequestURL().origin **預設**:跟隨使用者實際存取的網域
有 ISR 頁或預渲染頁的站台,都必須設定 NUXT_PUBLIC_SITE_URL。
原因是被快取的頁面拿不到請求標頭:nitro 的 cachedEventHandler 進 handler 前會把請求標頭只保留 cache.varies 白名單,Host 被丟掉 ⇒ useRequestURL() 退化成 http://localhost,canonical / og:url 全錯。預渲染期同理(那時根本沒有真實請求)。
實測對照(同一行程、同樣請求標頭,差異只由「是否被快取」決定):
未被快取的頁面 → canonical = https://真實網域/... ✅
被快取的頁面 → canonical = http://localhost/... ❌
自查:curl -sI https://<網域>/<某 ISR 頁> 看 canonical,不得出現 localhost。
🔴 L2 是 opt-in 的收斂開關,不是預設行為。 預設走 L3 跟隨存取網域 —— 理由來自真實約束:①開發常不知道正式生產網域 ②生產網域會換 ③一個產品往往有多個網域。
設了 NUXT_PUBLIC_SITE_URL 才收斂 —— 那是營運決策。⚠️ 收斂對多網域產品有害:其餘網域的頁面全部 canonical 到這一個,等於自己宣告「我是副本」、可能被搜尋引擎剔除。這類產品應留空該變數,或用 L1 逐頁覆寫。
⚠️ 有 prerender: true 頁的專案必須設該變數(建構期沒有真實請求)⇒ 無法靠留空退出收斂,多網域場景只能用 L1 逐頁覆寫。
⚠️ L3 能正常運作,靠的是 framework 在 isr → cache 翻譯時固定注入 varies: ['host','x-forwarded-proto']。
原先的一層理由(保留供參考):被快取的 HTML 是全體訪客共用的,把「本次請求的 Host」烤進去在原理上就不對;canonical 的語意本就是「首選網域」,取站台設定反而更準。代價:從副網域存取也輸出主網域 canonical(這正是 canonical 該做的),og:url 同樣跟隨 —— 確實需要隨存取網域變化的頁面用 L1 覆寫。未設定該變數的專案行為不變(走 L3)。
預渲染(prerender / SSG)origin(framework#117):prerender 期
useRequestURL().origin是建構機的http://localhost,會把og:image/canonical烤成 localhost、分享時社交爬蟲抓不到。故useSeo在預渲染期改取建構期環境變數NUXT_PUBLIC_SITE_URL,缺該變數直接建構報錯(暴露誤配而非靜默劣化)。有預渲染頁的專案,建構環境必須設NUXT_PUBLIC_SITE_URL=https://你的網域;ISR / SSR / dev 不受影響。
useRequestURL().origin 完全依賴 nginx / cloudflare 透傳 Host / X-Forwarded-Host / X-Forwarded-Proto。透傳漂移會讓 SSR 拿到內網位址污染 SEO —— framework 在 CSR hydration 階段比對 <link rel="canonical"> 與 window.location.origin,不一致即 dev console.warn / prod console.error 一次性診斷 + 修復指引,運維必須修。
完整診斷指引:nginx origin 透傳設定。
為什麼不拿
systemStore.info.site_url兜底:它的語義跟「使用者實際存取網域」脫節(framework#87),origin 鏈裡沒有它這一層——SystemInfo.site_url欄位仍保留供業務讀取(如「最新地址」跳轉),與 SEO 解耦。⚠️
NUXT_PUBLIC_SITE_URL(即runtimeConfig.public.siteUrl)不在此列:它自 framework#278 起就是上面優先級鏈的 L2,設定了即收斂、優先於請求標頭,不是「僅 build-time 用」。
image 兜底鏈:顯式 input.image → 本地 /og-image.png(#89 起不再讀後端 header.og_image / header.img)。
og-image 佔位圖(1200×630 PNG,prebuild 強制)
每個專案 public/og-image.png 必須存在(1200×630 PNG,≤8MB),由 scripts/check-og-image.ts
在 prebuild 鉤子檢查,缺失則 build 失敗。
業務詳情頁推薦用真實封面圖覆蓋:
<SwImage
:src="data.cover"
alt="..."
ratio="16/9"
loading="eager"
fetchpriority="high"
preload ← LCP 最佳化:自動注入 <link rel="preload">
/>
dev 環境關鍵字堆砌警告
業界 2026 SEO 標準下,搜尋引擎對過長的 title / description 會反向降權。useSeo 在 dev 環境 console.warn 提示:
| 欄位 | 建議 | 觸發警告 |
|---|---|---|
| title | 10–60 字元 | > 70 |
| description | 50–160 字元 | > 200 |
| keywords | ≤10 個、≤200 字元 | 數量 > 10 或字元 > 200 |
生產建置 dead code elimination 後零開銷,僅 dev 環境提示運營 / 開發人員。
警告輸出帶上頁面 URL 方便直接跳轉排查,同時附一行連結到 SEO 知識庫 wiki。
靜音單頁(私有 / 工具型)
私有頁(如 /admin、/internal-tools)的 title / description 通常更短,頻繁告警噪音大。
傳 silenceLengthWarnings: true 關閉長度告警:
useSeo({
title: '內部工具',
description: '僅內部使用',
robots: 'private',
silenceLengthWarnings: true,
})
響應式輸入與單集主實體
useSeo(source: MaybeRefOrGetter<SeoInput>): void 接受同步 getter、ref 或 computed,並隨資料更新 title、description、canonical、OG 與 schema。普通 SeoInput 物件仍是呼叫時的快照;不要把 data.value 複製進普通物件後期待切集更新,也不要傳非同步 getter。
JSON-LD 預設在 SSR 輸出;dev 允許客戶端更新,正式環境客戶端預設不更新。需要在同一頁面實例內切集同步 JSON-LD 時,在 nuxt.config.ts 明確設定 schemaOrg: { reactive: true }。head 響應式輸入與正式環境 schema 更新是兩個開關;dev 正常不能取代正式環境導覽驗收。
media.url?: string 是穩定的作品網址,預設目前 canonical;單集頁必須明確傳整劇網址。media.mainEntity?: 'work' | 'episode' 預設作品;僅 kind: 'series' 且有 episode 時可選單集,否則回退作品。episode.url 預設目前 canonical;單集 @id 使用該集 URL + #episode,partOfSeries 指回穩定作品 @id。 完整欄位和範例見 useSeo API。
本節 API 為本地新增,待 framework#430 發布。消費方須按實際安裝版本進行完整 XML 失敗 / 恢復驗收;本地通過不等於正式環境部署或收錄。
JSON-LD 結構化資料
useSeo 預設輸出 WebSite + WebPage schema,詳情頁傳 type: 'article' + article 欄位額外輸出 Article schema。
業務可在 page 內額外呼叫 useSchemaOrg() 輸出自訂 schema(如 Movie / Recipe / Product)。
匯入路徑:必須從
@unhead/schema-org/vue匯入(不是nuxt-schema-org/schema,兩者簽名不同):import { useSchemaOrg, defineMovie } from '@unhead/schema-org/vue' useSchemaOrg([defineMovie({ name, datePublished, /* ... */ })])
sitemap.xml 與 robots.txt(動態生成)
/sitemap.xml由@nuxtjs/sitemap模組自動生成,含靜態路由 + 顯式註冊的動態路由- sitemap 預設不帶 XSL 展示頁(
xsl: false,framework#445):Chrome 正在移除 XSLT,帶xml-stylesheet的 sitemap 在瀏覽器裡會跳出棄用提示;爬蟲讀原始 XML,不受影響。需要時可在頂層sitemap.xsl重新開啟,此時展示頁站點名預設取app.head.title回填site.name(#237),顯式設定site.name可覆蓋
- sitemap 預設不帶 XSL 展示頁(
/robots.txt由@nuxtjs/robots模組自動生成,框架預設配置:
User-agent: *
Allow: /
Disallow: /api/
Sitemap: <siteUrl>/sitemap.xml
必要源設定、錯誤狀態和驗收見 Sitemap 指南。
siteUrl 由 NUXT_PUBLIC_SITE_URL 鎖定(framework#85)
sitemap.xml 必須輸出穩定的主網域給搜尋引擎,由 process.env.NUXT_PUBLIC_SITE_URL 鎖定,跟 useSeo runtime origin(useRequestURL().origin)解耦 —— 跟使用者存取網域變化無關。
framework 在 module setup 階段對此變數做 host 格式校驗(不是合法 URL / 協定非 http(s) / 帶 path 或 query 都會 warn),見 nginx origin 透傳設定 → NUXT_PUBLIC_SITE_URL 設定。
預設 exclude 私有路徑(framework#85)
framework 預設 sitemap exclude 通用私有路徑,避免 sitemap.xml 跟 useSeo({ robots: 'private' | 'noindex' }) 自相矛盾:
/login, /register, /forgot,
/mine/**, /profile/**, /account/**,
/admin/**, /dashboard/**,
/search, /oauth/**
業務自訂私有路徑(如中轉頁 /link)透過 swift.config.json 追加:
{
"framework": {
"seo": {
"sitemap": {
"exclude": ["/link", "/promo-draft"]
}
}
}
}
詳見 ModuleOptions → seo.sitemap。
i18n strategy 與 hreflang(framework#85 / #88)
啟用 @nuxtjs/i18n 時建議使用 strategy: 'prefix' 或 'prefix_except_default',讓三語 URL 有獨立路徑(如 /, /zh-TW/, /en/),sitemap.xml 才能輸出 <xhtml:link rel="alternate" hreflang="..."> 互鏈讓搜尋引擎正確識別多語言版本。
多語言站的 hreflang 互鏈已內建、零設定:i18n 啟用且 locales ≥ 2 且 strategy 非 no_prefix 時,sitemap 自動按 locale 拆分(/sitemap.xml → sitemapindex + 每語言一份),每條 <url> 輸出全語言 <xhtml:link rel="alternate" hreflang> 互鏈(含 x-default)。單語言站保持單份 sitemap 不變;確需覆蓋走 nuxt.config.ts 頂級 sitemap.autoI18n。hreflang 是絕對 URL,多語言站需設定 NUXT_PUBLIC_SITE_URL。
strategy: 'no_prefix'(三語共用同一 URL)會被 framework dev console.warn 提示,且 sitemap 的 hreflang 集成自動跳過 —— 無獨立 URL 就沒有可互鏈的物件。
AI bot 抓取策略
swift.config.json 唯一暴露的 SEO 選項:
{
"framework": {
"seo": {
"aiBotPolicy": "allow"
}
}
}
'allow'(預設):robots.txt 不對任何 AI bot 單獨設限,最大化 AI Overviews / 答案引用曝光'disallow':robots.txt 對下列 UA 整站Disallow: /(適合訂閱 / 付費內容站):GPTBot/CCBot/ClaudeBot/anthropic-ai/PerplexityBot/Bytespider/Google-Extended
disallow 攔的不只是訓練爬蟲,也不是全部 AI 爬蟲(fw#381):清單裡 PerplexityBot 是 Perplexity 的搜尋檢索爬蟲、Google-Extended 管 Gemini 的 grounding——開 disallow 會把這兩條 AI 搜尋流量一起擋掉;而 ChatGPT 搜尋用的 OAI-SearchBot / ChatGPT-User 與 Claude-SearchBot 不在清單裡,仍按 * 放行。所以它的實際語義是「攔訓練 + 攔 Perplexity / Gemini 檢索,放過 ChatGPT / Claude 檢索」,選它之前要接受這一點。「只攔訓練、放過所有檢索」的 search-only 檔只預留了名字,未實作——真有站需要時再按當時各家 UA 清單落地,別照現在的清單猜。
Google 官方口徑:AI Overviews / AI Mode 由
Googlebot決定,不受Google-Extended影響,也不需要任何額外檔案或 schema;本開關不會影響 Google 自家的 AI 搜尋曝光。
SystemHeader 欄位約定
後端 /system/info 回傳值的 header 欄位:
| 欄位 | 用途 |
|---|---|
site_title | 站點標題。不參與 SEO(不再注入 titleTemplate、不再當 title 兜底)。站點名改由 applier 在 nuxt.config.ts 的 app.head.titleTemplate + runtimeConfig.public.siteName 提供,源自 swift.config.json 的 project.title。此欄位仍供業務讀取 |
description | 站點描述 |
keywords | 站點關鍵字 |
og_image | OG 預設分享圖 URL(運營 CMS 上傳 1200×630 PNG/JPG) |
img | 舊後端相容欄位(新代碼一律用 og_image);與 og_image 長期共存相容,待後端全升級後再清除,不預設具體版本 |
site_url | 站點根域名(多副域名場景的 canonical 主源) |
常見問題
為啥不引入 nuxt-og-image?
滿文字 satori 動態生圖執行時壓力大、出圖不美觀。框架推薦運營在 CMS 上傳真實封面圖,
業務詳情頁用 data.cover 等真實封面,遠比 satori 文字圖好看。
動態路由怎麼進 sitemap?
靜態路由自動收錄;動態詳情頁(/movie/[id] 等)framework 自動發現不了,走標準
sitemap.sources 端點(defineSitemapEventHandler + 後端 ID 資料源),見
Sitemap 站點地圖。
私有頁加了 robots: 'private',sitemap 還會包含嗎?
不會。framework 預設已 exclude 私有路徑(framework#85,見上文「預設 exclude 私有路徑」);
業務自定義私有路徑走 swift.config.json 的 framework.seo.sitemap.exclude 追加。