SSR しないことによる課題
特に SEO 要件が厳しくない場合、SSR をしない SPA にする方が諸々実装が楽であることが多い。
しかし、完全な CSR だと <title>
や <meta>
もブラウザ上で処理されるため、SNS 上でのリンクプレビューなどが全くできないという問題がある。
全てのページで共通の静的なものを設定するのは nuxt.config.ts
上でできるが、ここではページごとに動的にタイトルを設定する場合を考える。
解決策
Nuxt3 は Nitro というサーバーエンジンを使っており、CSR の場合も Nitro によって HTML を配信する。(生成して静的ホストする場合を除く。)
Nitro のプラグインを用いて <title>
と <meta>
を HTML に埋め込むことで、SSR していない Nuxt でもリンクプレビューに必要な情報のみを SSR することができる。
ただし、その場合 useHead
や useSEOMeta
といった 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)}" />`;
ここではとりあえず title
と description
と og: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.name
や route.params
などを利用することができる。(エラー処理は省略している。)fs.readFile
を待つ必要があるので一応 Promise でラップしているが、これはサーバー起動直後のわずかな時間である。
例えば route.params
のユーザーIDをもとに API からユーザー情報を取得して、title
や description
に利用することもできる。
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 していくというのが本当は良さそう。