ymmooot

SSR してない Nuxt で <title> と <meta> タグだけ SSR する

SSR しないことによる課題

特に SEO 要件が厳しくない場合、SSR をしない SPA にする方が諸々実装が楽であることが多い。
しかし、完全な CSR だと <title> や <meta> もブラウザ上で処理されるため、SNS 上でのリンクプレビューなどが全くできないという問題がある。


全てのページで共通の静的なものを設定するのは nuxt.config.ts 上でできるが、ここではページごとに動的にタイトルを設定する場合を考える。

解決策

Nuxt3 は Nitro というサーバーエンジンを使っており、CSR の場合も Nitro によって HTML を配信する。(生成して静的ホストする場合を除く。)
Nitro のプラグインを用いて <title><meta> を HTML に埋め込むことで、SSR していない Nuxt でもリンクプレビューに必要な情報のみを SSR することができる。


ただし、その場合 useHeaduseSEOMeta といった Nuxt(unhead) の composable は利用できないため、タグの生成等は自前で実装する必要がある。

実装

Nitro のプラグインは Nuxt3 プロジェクトの /server/plugins/ 配下に配置することで自動的に読み込まれるが、それゆえにファイルで分割することができない。
そのためここでは /ssr-meta/index.ts を作成し、必要に応じてファイル分割を行えるようにする。

/ssr-meta/index.tsexport default defineNitroPlugin((nitroApp: NitroApp) => {
  nitroApp.hooks.hook('render:html', async (html, { event }) => {
    html.head.push('<title>Hello world!</title>');
  });
});

/server/plugins/ 配下にない場合、nuxt.config.ts で以下のように登録できる。
(/server/plugins/ssr-meta.ts を作成して再エクスポートしても良い。)

nuxt.config.tsexport default defineNuxtConfig({
  ssr: false,
  nitro: {
    plugins: ['@/ssr-meta/index.ts'],
  },
});

defineNitroPlugin は Nitro のプラグインを定義する関数で、サーバーの初期化時に実行される。
render:html フックは HTML をレンダリングする度に実行されるので、ここで <title> タグを追加することで SSR することができる。
ここまでで、とりあえず全てのページのタイトルを Hello world! にすることができる。

タグ生成

タグ生成用の関数を作成しておく。忘れずにサニタイズすること。
例えば /users/[id].vue のようなルーティングで、タイトルにユーザー名を含めるような場合に容易に XSS が発生する。

/ssr-meta/tag.tsexport const titleTag = (str: string) => {
  const title = `${sanitizeHtml(str)} | Hello`;
  return `<title>${title}</title><meta property="og:title" content="${title}"/>`;
};
export const descriptionTag = (str: string) => {
  const description = sanitizeHtml(str);
  return `<meta name="description" content="${description}"/><meta property="og:description" content="${description}"/>`;
};
export const ogImageTag = (src: string) => `<meta property="og:image" content="${sanitizeHtml(src)}" />`;

ここではとりあえず titledescriptionog:image を生成する関数を作成した。

パスによる分岐

render:html hook 第二引数の context から event.path を取得することで、リクエストパスによって処理を分岐させることができる。

/ssr-meta/index.tsexport default defineNitroPlugin((nitroApp: NitroApp) => {
  nitroApp.hooks.hook('render:html', async (html, { event }) => {
    const path = event.path;
    if (path === '/') {
      html.head.push(titleTag('Home'));
      html.head.push(descriptionTag('Home description.'));
    }
    if (path === '/about') {
      html.head.push(titleTag('About'));
      html.head.push(descriptionTag('About description.'));
    }
  });
});

これでやりたいことのほとんどはできている。

Nuxt と同じルーティング情報を扱う

ここまでの実装では event.path によって分岐させているが、これはただの文字列であり扱いにくい。
そこでやや無理やりではあるが以下のように CSR アプリケーションのビルド時にルーティング情報を json に書き出す。

nuxt.config.tsexport default defineNuxtConfig({
  ssr: false,
  nitro: {
    plugins: ['@/ssr-meta/index.ts'],
  },
  hooks: {
    'pages:extend'(pages) {
      fs.writeFile('./ssr-meta/routes.gen.json', JSON.stringify(pages));
    },
  },
});

そしてそれをサーバーの初期化時に読み込み、router を作る。

/ssr-meta/index.tsexport default defineNitroPlugin((nitroApp: NitroApp) => {
  // vue-router のインスタンスを作成する
  const promise = new Promise<Router>(async (resolve) => {
    const data = await fs.readFile('./ssr-meta/routes.gen.json', 'utf-8');
    const routes = JSON.parse(data) as RouteRecordRaw[];
    const router = createRouter({
      routes,
      // history の機能は使わないので RouterHistory interface を満たせばなんでもいい
      history: createMemoryHistory(),
    });
    resolve(router);
  });

  nitroApp.hooks.hook('render:html', async (html, { event }) => {
    // 初期化が終わってない場合は待つ
    const router = await promise;
    // path から route を取得する
    const route = router.resolve(event.path);
    console.log(route?.name);
    console.log(route?.params);
    html.head.push(titleTag(route?.name.toString() ?? ''));
  });
});

これで route.nameroute.params などを利用することができる。(エラー処理は省略している。)
fs.readFile を待つ必要があるので一応 Promise でラップしているが、これはサーバー起動直後のわずかな時間である。
例えば route.params のユーザーIDをもとに API からユーザー情報を取得して、titledescription に利用することもできる。
API 呼び出しを行う際は、キャッシュを利用するなどしてパフォーマンスを考慮する必要がある。


あとは route による条件分岐を書いていけば良い。
ページ数による分岐が多かったり、ページによって必要な依存が異なったりする場合はストラテジーパターンなどを利用してうまく実装すると良い。

注意点

ここまで読めばわかる通り、SSR をしない Nuxt のサーバーで Nuxt コンテキストの情報を扱うのはかなり無理がある。
当たり前だが、ブラウザで表示されるタイトルはコンポーネントサイドで別途 useHead などを利用して設定する必要がある。


以下、この記事で紹介した手法を ssr-meta 、Nuxt のコンポーネントによるタグ設定を CSR と呼ぶ。

  • ssr-meta タグと CSR で設定したタグが異なる

この場合は、ブラウザで JavaScript が実行された瞬間に CSR のタグで上書きされる。
可能な限り実装を共通化して、齟齬が生じないようにする必要がある。

  • ssr-meta タグがあるページから SPA 遷移をした場合、遷移先に CSR タグがない場合はタイトルが変わらない

ssr-meta によって HTML にタグを埋め込んだ場合、上書きされるか明示的に消すまで残り続ける。
CSR でタグを設定しないページが存在する場合は注意が必要である。

まとめ

あまりおすすめできる手法ではないが、実現は可能ということで記しておく。
routeRules を使って部分的に CSR と SSR を使い分けることもできるので、とりあえず全体を SSR 可能なコードで作っておいて、必要な場所だけ SSR していくというのが本当は良さそう。