Skip to content

Vue 專案的 SEO 怎麼做?Nuxt SSR 與預渲染實戰

Vue 專案的 SEO:Nuxt SSR 與預渲染

Vue 加上 Vue Router 建的單頁應用程式,伺服器回傳的只有一個空容器,這就是 純 CSR 的 SEO 問題 ,這一篇談具體怎麼補。

三條解法與成本

解法改動幅度需要 Node 伺服器適合
只做預渲染少數頁面需要收錄
遷移到 Nuxt(SSG)內容型網站
遷移到 Nuxt(SSR)內容需要即時

先問「哪些頁面真的需要被收錄」

需要不需要
首頁、產品頁登入後的操作介面
內容頁、文章後台管理
關於、聯絡頁個人設定、購物車

如果只有前面五六頁需要,預渲染就夠了,不必為此重寫整個專案。

方案一:只做預渲染

在建置時把指定路由跑成靜態 HTML,其餘維持原狀。

bash
npm install -D vite-plugin-prerender
js
// 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 > *',
      }),
    }),
  ],
});

一定要確認產出真的有內容

bash
# 建置後檢查
grep -c '<h1' dist/index.html
grep -o 'og:title[^>]*' dist/index.html

有些設定會在程式碼還沒渲染完就擷取,產出的仍是空容器。renderAfterElementExists 或等待時間的設定就是為了避免這件事,它們屬於 renderer 的選項,寫在頂層不會生效。

涵蓋動態路由

從 API 或資料檔取得清單,展開成具體路由:

js
// 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 支援 逐路由設定,不必整站統一:

js
// 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: trueSSG內容不常變
isr: 秒數ISR大量頁面、可接受短暫過期
ssr: trueSSR內容需要即時
ssr: falseCSR登入後的操作介面

這是 Nuxt 最實用的一點

「渲染模式要整站一致」是常見的誤解。實際上行銷頁走 SSG、商品頁走 ISR、後台走 CSR 是完全合理的組合,各取所需。

全站預渲染

如果整個網站都是靜態內容:

js
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    prerender: {
      crawlLinks: true, // 自動跟著連結爬完整個網站
      routes: ['/'],
    },
  },
});
bash
npx nuxi generate

crawlLinks 會從首頁開始,跟著 <a href> 自動找出所有頁面,不必手動列舉。

crawlLinks 找不到孤兒頁面

沒有被任何連結指到的頁面會被漏掉。這與 孤兒頁面 是同一個問題,修正的方式也一樣:從相關頁面補上連結。

檔頭標籤的管理

Nuxt 的做法

在頁面元件裡宣告,由框架輸出:

vue
<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>

結構化資料同樣可以這樣輸出:

vue
<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 專案的做法

用檔頭管理套件在元件裡宣告:

bash
npm install @unhead/vue
js
// 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');
vue
<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

js
// 不建議
router.afterEach((to) => {
  document.title = to.meta.title;
});

三個問題:

  1. 伺服器端渲染時會出錯:那裡沒有 document
  2. 只能改標題:meta 標籤要自己一個個找出來改,很快就失控。
  3. 社群爬蟲讀不到:純 CSR 下這是動態操作。

路由守衛 適合做權限判斷與導向,不適合管檔頭標籤。

完整的路由層設定

把 SEO 相關的資訊放進路由的 meta,再由頁面元件取用:

js
// 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,
    },
  },
];
vue
<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>

預渲染的路由清單可以從這裡產生

js
// 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
檔頭標籤由頁面資料產生
canonicalog:url建置時依路由自動產生
sitemap.xml給定網域後自動產生
robots.txt建置結束時依環境輸出
頁面切換用戶端路由,即時

靜態產生加上用戶端接手,兼得 SEO 與體驗。 這也是文件站、部落格、官網最該優先考慮的架構。

檢查清單

項目標準
需要被收錄的頁面已預渲染或 SSR必備
檢視原始碼看得到內容與檔頭標籤必備
檔頭標籤用 useHead 而非操作 document必備
後台與登入頁標了 noindex必備
不存在的路徑回傳真正的 404必備
sitemap.xmlrobots.txt必備
每頁有 canonical建議
導覽連結是 <a href>必備
預渲染的路由清單與路由設定同源建議
依路由設定渲染模式而非整站統一建議

常見問題

既有的 Vue 專案一定要改成 Nuxt 嗎?

不一定。如果只有少數幾頁需要被收錄,加一個預渲染插件就能解決,改動非常小。真正需要遷移到 Nuxt 的情況是頁面數多、內容動態產生,或需要伺服器端的即時資料。

Nuxt 的四種渲染模式怎麼選?

內容不常變的用靜態產生、需要即時資料又要被收錄的用伺服器端渲染、大量頁面且可接受短暫過期的用增量靜態再生、登入後的操作介面用純用戶端渲染。它支援逐路由設定,不必整站統一。

檔頭標籤該在哪裡管理?

在頁面元件裡用 useHead 宣告,讓框架的檔頭管理機制輸出。不要寫在 路由守衛 裡直接操作 document,那樣在伺服器端渲染時會出錯,社群爬蟲也讀不到。

純 Vue 專案怎麼處理動態檔頭?

用檔頭管理套件在元件裡宣告,比在路由守衛裡改文件標題乾淨得多。但要記得純用戶端渲染的專案即使動態更新了標籤,社群爬蟲仍然讀不到,那需要預渲染才能解決。

預渲染能涵蓋動態路由嗎?

可以,但要在建置時就知道所有網址。做法是從後端 API 或資料檔取得清單,展開成具體的路由陣列傳給預渲染設定。網址數量很大或持續新增時就不適合了。

延伸閱讀

參考資料:Nuxt:Rendering Modes