Vue 專案的 SEO 怎麼做?Nuxt SSR 與預渲染實戰
用 Vue 加上 Vue Router 建的單頁應用程式,伺服器回傳的只有一個空容器,這就是 純 CSR 的 SEO 問題 ,這一篇談具體怎麼補。
三條解法與成本
| 解法 | 改動幅度 | 需要 Node 伺服器 | 適合 |
|---|---|---|---|
| 只做預渲染 | 小 | 否 | 少數頁面需要收錄 |
| 遷移到 Nuxt(SSG) | 中 | 否 | 內容型網站 |
| 遷移到 Nuxt(SSR) | 大 | 是 | 內容需要即時 |
先問「哪些頁面真的需要被收錄」
| 需要 | 不需要 |
|---|---|
| 首頁、產品頁 | 登入後的操作介面 |
| 內容頁、文章 | 後台管理 |
| 關於、聯絡頁 | 個人設定、購物車 |
如果只有前面五六頁需要,預渲染就夠了,不必為此重寫整個專案。
方案一:只做預渲染
在建置時把指定路由跑成靜態 HTML,其餘維持原狀。
npm install -D vite-plugin-prerender// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import prerender from 'vite-plugin-prerender';
import path from 'node:path';
const Renderer = prerender.PuppeteerRenderer;
export default defineConfig({
plugins: [
vue(),
prerender({
staticDir: path.join(process.cwd(), 'dist'),
routes: ['/', '/about', '/products', '/contact'],
renderer: new Renderer({
// 等到指定的元素出現才擷取 HTML
renderAfterElementExists: '#app > *',
}),
}),
],
});一定要確認產出真的有內容
# 建置後檢查
grep -c '<h1' dist/index.html
grep -o 'og:title[^>]*' dist/index.html有些設定會在程式碼還沒渲染完就擷取,產出的仍是空容器。renderAfterElementExists 或等待時間的設定就是為了避免這件事,它們屬於 renderer 的選項,寫在頂層不會生效。
涵蓋動態路由
從 API 或資料檔取得清單,展開成具體路由:
// vite.config.js
async function getProductRoutes() {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return products.map((p) => `/products/${p.slug}`);
}
export default defineConfig(async () => ({
plugins: [
vue(),
prerender({
staticDir: path.join(process.cwd(), 'dist'),
routes: ['/', '/about', ...(await getProductRoutes())],
}),
],
}));網址數量大或持續新增時就不適合
一千個商品要在建置時跑一千次瀏覽器渲染,建置時間會非常長。而且新商品上架要重新建置才會有靜態頁。
這種情況該考慮 Nuxt 的增量靜態再生。
方案二與三:遷移到 Nuxt
四種渲染模式
Nuxt 支援 逐路由設定,不必整站統一:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 首頁:建置時預渲染(SSG)
'/': { prerender: true },
// 行銷頁:同樣預渲染
'/about': { prerender: true },
'/pricing': { prerender: true },
// 商品頁:增量靜態再生,一小時後背景更新
'/products/**': { isr: 3600 },
// 新聞:每次請求都伺服器端渲染
'/news/**': { ssr: true },
// 後台:純用戶端渲染,不需要被收錄
'/admin/**': { ssr: false },
},
});| 設定 | 模式 | 適合 |
|---|---|---|
prerender: true | SSG | 內容不常變 |
isr: 秒數 | ISR | 大量頁面、可接受短暫過期 |
ssr: true | SSR | 內容需要即時 |
ssr: false | CSR | 登入後的操作介面 |
這是 Nuxt 最實用的一點
「渲染模式要整站一致」是常見的誤解。實際上行銷頁走 SSG、商品頁走 ISR、後台走 CSR 是完全合理的組合,各取所需。
全站預渲染
如果整個網站都是靜態內容:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
crawlLinks: true, // 自動跟著連結爬完整個網站
routes: ['/'],
},
},
});npx nuxi generatecrawlLinks 會從首頁開始,跟著 <a href> 自動找出所有頁面,不必手動列舉。
crawlLinks 找不到孤兒頁面
沒有被任何連結指到的頁面會被漏掉。這與 孤兒頁面 是同一個問題,修正的方式也一樣:從相關頁面補上連結。
檔頭標籤的管理
Nuxt 的做法
在頁面元件裡宣告,由框架輸出:
<script setup>
const { data: product } = await useFetch(`/api/products/${route.params.slug}`);
useHead({
title: () => `${product.value?.name} 商品介紹`,
meta: [
{ name: "description", content: () => product.value?.summary },
{ property: "og:title", content: () => product.value?.name },
{ property: "og:image", content: () => product.value?.ogImage },
{ property: "og:type", content: "product" },
],
link: [{ rel: "canonical", href: () => `https://example.com/products/${product.value?.slug}` }],
});
</script>結構化資料同樣可以這樣輸出:
<script setup>
useHead({
script: [
{
type: "application/ld+json",
innerHTML: () =>
JSON.stringify({
"@context": "https://schema.org",
"@type": "Product",
name: product.value?.name,
offers: {
"@type": "Offer",
price: String(product.value?.price),
priceCurrency: "TWD",
},
}),
},
],
});
</script>用 SSR 或 SSG 時這些標籤才進得了 HTML
useHead 在伺服器端渲染時會把標籤寫進回傳的 HTML,社群爬蟲讀得到。純 CSR 模式下它只是在瀏覽器裡動態插入,社群爬蟲仍然讀不到。
這是「渲染模式」與「檔頭標籤」互相牽連的地方。
純 Vue 專案的做法
用檔頭管理套件在元件裡宣告:
npm install @unhead/vue// main.js
import { createApp } from 'vue';
import { createHead } from '@unhead/vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
app.use(router);
app.use(createHead());
app.mount('#app');<script setup>
import { useHead } from "@unhead/vue";
const props = defineProps({ product: Object });
useHead({
title: () => `${props.product?.name} 商品介紹`,
meta: [{ name: "description", content: () => props.product?.summary }],
});
</script>不要在路由守衛裡直接操作 document
// 不建議
router.afterEach((to) => {
document.title = to.meta.title;
});三個問題:
- 伺服器端渲染時會出錯:那裡沒有
document。 - 只能改標題:meta 標籤要自己一個個找出來改,很快就失控。
- 社群爬蟲讀不到:純 CSR 下這是動態操作。
路由守衛 適合做權限判斷與導向,不適合管檔頭標籤。
完整的路由層設定
把 SEO 相關的資訊放進路由的 meta,再由頁面元件取用:
// router/index.js
const routes = [
{
path: '/',
component: () => import('../pages/Home.vue'),
meta: {
title: '某某公司|網頁設計與前端開發',
description: '提供網頁設計、前端開發與效能優化服務。',
prerender: true,
},
},
{
path: '/admin',
component: () => import('../pages/Admin.vue'),
meta: {
// 後台不需要被收錄
robots: 'noindex, nofollow',
prerender: false,
},
},
];<script setup>
import { useRoute } from "vue-router";
import { useHead } from "@unhead/vue";
import { computed } from "vue";
const route = useRoute();
useHead({
title: computed(() => route.meta.title),
meta: computed(() => [
{ name: "description", content: route.meta.description },
// 只有需要時才輸出 robots
...(route.meta.robots ? [{ name: "robots", content: route.meta.robots }] : []),
]),
});
</script>預渲染的路由清單可以從這裡產生
// vite.config.js
import { routes } from './src/router/routes.js';
const prerenderRoutes = routes.filter((r) => r.meta?.prerender).map((r) => r.path);一處定義,兩處使用,不必在路由設定與建置設定裡各維護一份清單。
其他要一起處理的
遷移或加上預渲染之後,這幾件事仍要另外做:
| 項目 | 見 |
|---|---|
| 不存在的路徑要回傳真正的 404 | 轉址與 HTTP 狀態碼 |
產生 sitemap.xml | 網站地圖 |
依環境產生 robots.txt | 爬蟲規則 |
每頁的 canonical | 網址正規化 |
導覽連結用 <a href> | 內部連結與錨點文字 |
| 水合造成的互動延遲 | 水合 Hydration 與 INP |
Nuxt 有現成的模組
@nuxtjs/sitemap 與 @nuxtjs/robots 這類模組能自動處理網站地圖與爬蟲規則,不必自己寫腳本。
RouterLink 預設就渲染成 <a href>,所以連結那一項通常已經沒問題,要留意的是自己用 <button> 加 router.push 手寫的版本。
靜態產生為什麼適合內容型網站
靜態站產生器在建置時把每一頁寫成完整的 HTML,同時在瀏覽器端接手路由,所以它天生沒有 CSR 的 SEO 問題:
| 項目 | 處理方式 |
|---|---|
| 內容 | 建置時寫進 HTML |
| 檔頭標籤 | 由頁面資料產生 |
canonical 與 og:url | 建置時依路由自動產生 |
sitemap.xml | 給定網域後自動產生 |
robots.txt | 建置結束時依環境輸出 |
| 頁面切換 | 用戶端路由,即時 |
靜態產生加上用戶端接手,兼得 SEO 與體驗。 這也是文件站、部落格、官網最該優先考慮的架構。
檢查清單
| 項目 | 標準 |
|---|---|
| 需要被收錄的頁面已預渲染或 SSR | 必備 |
| 檢視原始碼看得到內容與檔頭標籤 | 必備 |
檔頭標籤用 useHead 而非操作 document | 必備 |
後台與登入頁標了 noindex | 必備 |
| 不存在的路徑回傳真正的 404 | 必備 |
有 sitemap.xml 與 robots.txt | 必備 |
每頁有 canonical | 建議 |
導覽連結是 <a href> | 必備 |
| 預渲染的路由清單與路由設定同源 | 建議 |
| 依路由設定渲染模式而非整站統一 | 建議 |
常見問題
既有的 Vue 專案一定要改成 Nuxt 嗎?
不一定。如果只有少數幾頁需要被收錄,加一個預渲染插件就能解決,改動非常小。真正需要遷移到 Nuxt 的情況是頁面數多、內容動態產生,或需要伺服器端的即時資料。
Nuxt 的四種渲染模式怎麼選?
內容不常變的用靜態產生、需要即時資料又要被收錄的用伺服器端渲染、大量頁面且可接受短暫過期的用增量靜態再生、登入後的操作介面用純用戶端渲染。它支援逐路由設定,不必整站統一。
檔頭標籤該在哪裡管理?
在頁面元件裡用 useHead 宣告,讓框架的檔頭管理機制輸出。不要寫在 路由守衛 裡直接操作 document,那樣在伺服器端渲染時會出錯,社群爬蟲也讀不到。
純 Vue 專案怎麼處理動態檔頭?
用檔頭管理套件在元件裡宣告,比在路由守衛裡改文件標題乾淨得多。但要記得純用戶端渲染的專案即使動態更新了標籤,社群爬蟲仍然讀不到,那需要預渲染才能解決。
預渲染能涵蓋動態路由嗎?
可以,但要在建置時就知道所有網址。做法是從後端 API 或資料檔取得清單,展開成具體的路由陣列傳給預渲染設定。網址數量很大或持續新增時就不適合了。
延伸閱讀
參考資料:Nuxt:Rendering Modes