ymmooot

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

SSR しないことによる課題

特に SEO 要件が厳しくない場合、SSR をしない SPA にする方が諸々実装が楽であることが多い。
しかし、完全な CSR だと </code> や <code class="codespan"><meta></code> もブラウザ上で処理されるため、SNS 上でのリンクプレビューなどが全くできないという問題がある。</p> <br><p>全てのページで共通の静的なものを設定するのは <code class="codespan">nuxt.config.ts</code> 上でできるが、ここではページごとに動的にタイトルを設定する場合を考える。</p> <h2>解決策</h2><p>Nuxt3 は Nitro というサーバーエンジンを使っており、CSR の場合も Nitro によって HTML を配信する。(生成して静的ホストする場合を除く。)<br>Nitro のプラグインを用いて <code class="codespan"><title></code> と <code class="codespan"><meta></code> を HTML に埋め込むことで、SSR していない Nuxt でもリンクプレビューに必要な情報のみを SSR することができる。</p> <br><p>ただし、その場合 <code class="codespan">useHead</code> や <code class="codespan">useSEOMeta</code> といった Nuxt(unhead) の composable は利用できないため、タグの生成等は自前で実装する必要がある。</p> <h3>実装</h3><p>Nitro のプラグインは Nuxt3 プロジェクトの <code class="codespan">/server/plugins/</code> 配下に配置することで自動的に読み込まれるが、それゆえにファイルで分割することができない。<br>そのためここでは <code class="codespan">/ssr-meta/index.ts</code> を作成し、必要に応じてファイル分割を行えるようにする。</p> <pre class="hljs code-pre hljs-monokai-sublime code-pre--with-filename"><span class="filename">/ssr-meta/index.ts</span><code class="code"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title function_">defineNitroPlugin</span>(<span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">nitroApp</span>: <span class="hljs-title class_">NitroApp</span></span>) =></span> { nitroApp.<span class="hljs-property">hooks</span>.<span class="hljs-title function_">hook</span>(<span class="hljs-string">'render:html'</span>, <span class="hljs-title function_">async</span> (html, { event }) => { html.<span class="hljs-property">head</span>.<span class="hljs-title function_">push</span>(<span class="hljs-string">'<title>Hello world!</title>'</span>); }); }); </code></pre><p><code class="codespan">/server/plugins/</code> 配下にない場合、<code class="codespan">nuxt.config.ts</code> で以下のように登録できる。<br>(<code class="codespan">/server/plugins/ssr-meta.ts</code> を作成して再エクスポートしても良い。)</p> <pre class="hljs code-pre hljs-monokai-sublime code-pre--with-filename"><span class="filename">nuxt.config.ts</span><code class="code"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title function_">defineNuxtConfig</span>({ <span class="hljs-attr">ssr</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">nitro</span>: { <span class="hljs-attr">plugins</span>: [<span class="hljs-string">'@/ssr-meta/index.ts'</span>], }, }); </code></pre><p><code class="codespan">defineNitroPlugin</code> は Nitro のプラグインを定義する関数で、サーバーの初期化時に実行される。<br><code class="codespan">render:html</code> フックは HTML をレンダリングする度に実行されるので、ここで <code class="codespan"><title></code> タグを追加することで SSR することができる。<br>ここまでで、とりあえず全てのページのタイトルを <code class="codespan">Hello world!</code> にすることができる。</p> <h3>タグ生成</h3><p>タグ生成用の関数を作成しておく。忘れずにサニタイズすること。<br>例えば <code class="codespan">/users/[id].vue</code> のようなルーティングで、タイトルにユーザー名を含めるような場合に容易に XSS が発生する。</p> <pre class="hljs code-pre hljs-monokai-sublime code-pre--with-filename"><span class="filename">/ssr-meta/tag.ts</span><code class="code"><span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title function_">titleTag</span> = (<span class="hljs-params"><span class="hljs-attr">str</span>: <span class="hljs-built_in">string</span></span>) => { <span class="hljs-keyword">const</span> title = <span class="hljs-string">`<span class="hljs-subst">${sanitizeHtml(str)}</span> | Hello`</span>; <span class="hljs-keyword">return</span> <span class="hljs-string">`<title><span class="hljs-subst">${title}</span></title><meta property="og:title" content="<span class="hljs-subst">${title}</span>"/>`</span>; }; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title function_">descriptionTag</span> = (<span class="hljs-params"><span class="hljs-attr">str</span>: <span class="hljs-built_in">string</span></span>) => { <span class="hljs-keyword">const</span> description = <span class="hljs-title function_">sanitizeHtml</span>(str); <span class="hljs-keyword">return</span> <span class="hljs-string">`<meta name="description" content="<span class="hljs-subst">${description}</span>"/><meta property="og:description" content="<span class="hljs-subst">${description}</span>"/>`</span>; }; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title function_">ogImageTag</span> = (<span class="hljs-params"><span class="hljs-attr">src</span>: <span class="hljs-built_in">string</span></span>) => <span class="hljs-string">`<meta property="og:image" content="<span class="hljs-subst">${sanitizeHtml(src)}</span>" />`</span>; </code></pre><p>ここではとりあえず <code class="codespan">title</code> と <code class="codespan">description</code> と <code class="codespan">og:image</code> を生成する関数を作成した。</p> <h3>パスによる分岐</h3><p><code class="codespan">render:html</code> hook 第二引数の context から <code class="codespan">event.path</code> を取得することで、リクエストパスによって処理を分岐させることができる。</p> <pre class="hljs code-pre hljs-monokai-sublime code-pre--with-filename"><span class="filename">/ssr-meta/index.ts</span><code class="code"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title function_">defineNitroPlugin</span>(<span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">nitroApp</span>: <span class="hljs-title class_">NitroApp</span></span>) =></span> { nitroApp.<span class="hljs-property">hooks</span>.<span class="hljs-title function_">hook</span>(<span class="hljs-string">'render:html'</span>, <span class="hljs-title function_">async</span> (html, { event }) => { <span class="hljs-keyword">const</span> path = event.<span class="hljs-property">path</span>; <span class="hljs-keyword">if</span> (path === <span class="hljs-string">'/'</span>) { html.<span class="hljs-property">head</span>.<span class="hljs-title function_">push</span>(<span class="hljs-title function_">titleTag</span>(<span class="hljs-string">'Home'</span>)); html.<span class="hljs-property">head</span>.<span class="hljs-title function_">push</span>(<span class="hljs-title function_">descriptionTag</span>(<span class="hljs-string">'Home description.'</span>)); } <span class="hljs-keyword">if</span> (path === <span class="hljs-string">'/about'</span>) { html.<span class="hljs-property">head</span>.<span class="hljs-title function_">push</span>(<span class="hljs-title function_">titleTag</span>(<span class="hljs-string">'About'</span>)); html.<span class="hljs-property">head</span>.<span class="hljs-title function_">push</span>(<span class="hljs-title function_">descriptionTag</span>(<span class="hljs-string">'About description.'</span>)); } }); }); </code></pre><p>これでやりたいことのほとんどはできている。</p> <h3>Nuxt と同じルーティング情報を扱う</h3><p>ここまでの実装では <code class="codespan">event.path</code> によって分岐させているが、これはただの文字列であり扱いにくい。<br>そこでやや無理やりではあるが以下のように CSR アプリケーションのビルド時にルーティング情報を json に書き出す。</p> <pre class="hljs code-pre hljs-monokai-sublime code-pre--with-filename"><span class="filename">nuxt.config.ts</span><code class="code"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title function_">defineNuxtConfig</span>({ <span class="hljs-attr">ssr</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">nitro</span>: { <span class="hljs-attr">plugins</span>: [<span class="hljs-string">'@/ssr-meta/index.ts'</span>], }, <span class="hljs-attr">hooks</span>: { <span class="hljs-string">'pages:extend'</span>(pages) { fs.<span class="hljs-title function_">writeFile</span>(<span class="hljs-string">'./ssr-meta/routes.gen.json'</span>, <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(pages)); }, }, }); </code></pre><p>そしてそれをサーバーの初期化時に読み込み、router を作る。</p> <pre class="hljs code-pre hljs-monokai-sublime code-pre--with-filename"><span class="filename">/ssr-meta/index.ts</span><code class="code"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title function_">defineNitroPlugin</span>(<span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">nitroApp</span>: <span class="hljs-title class_">NitroApp</span></span>) =></span> { <span class="hljs-comment">// vue-router のインスタンスを作成する</span> <span class="hljs-keyword">const</span> promise = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Promise</span><<span class="hljs-title class_">Router</span>>(<span class="hljs-title function_">async</span> (resolve) => { <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> fs.<span class="hljs-title function_">readFile</span>(<span class="hljs-string">'./ssr-meta/routes.gen.json'</span>, <span class="hljs-string">'utf-8'</span>); <span class="hljs-keyword">const</span> routes = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(data) <span class="hljs-keyword">as</span> <span class="hljs-title class_">RouteRecordRaw</span>[]; <span class="hljs-keyword">const</span> router = <span class="hljs-title function_">createRouter</span>({ routes, <span class="hljs-comment">// history の機能は使わないので RouterHistory interface を満たせばなんでもいい</span> <span class="hljs-attr">history</span>: <span class="hljs-title function_">createMemoryHistory</span>(), }); <span class="hljs-title function_">resolve</span>(router); }); nitroApp.<span class="hljs-property">hooks</span>.<span class="hljs-title function_">hook</span>(<span class="hljs-string">'render:html'</span>, <span class="hljs-title function_">async</span> (html, { event }) => { <span class="hljs-comment">// 初期化が終わってない場合は待つ</span> <span class="hljs-keyword">const</span> router = <span class="hljs-keyword">await</span> promise; <span class="hljs-comment">// path から route を取得する</span> <span class="hljs-keyword">const</span> route = router.<span class="hljs-title function_">resolve</span>(event.<span class="hljs-property">path</span>); <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(route?.<span class="hljs-property">name</span>); <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(route?.<span class="hljs-property">params</span>); html.<span class="hljs-property">head</span>.<span class="hljs-title function_">push</span>(<span class="hljs-title function_">titleTag</span>(route?.<span class="hljs-property">name</span>.<span class="hljs-title function_">toString</span>() ?? <span class="hljs-string">''</span>)); }); }); </code></pre><p>これで <code class="codespan">route.name</code> や <code class="codespan">route.params</code> などを利用することができる。(エラー処理は省略している。)<br><code class="codespan">fs.readFile</code> を待つ必要があるので一応 Promise でラップしているが、これはサーバー起動直後のわずかな時間である。<br>例えば <code class="codespan">route.params</code> のユーザーIDをもとに API からユーザー情報を取得して、<code class="codespan">title</code> や <code class="codespan">description</code> に利用することもできる。<br>API 呼び出しを行う際は、キャッシュを利用するなどしてパフォーマンスを考慮する必要がある。</p> <br><p>あとは <code class="codespan">route</code> による条件分岐を書いていけば良い。<br>ページ数による分岐が多かったり、ページによって必要な依存が異なったりする場合はストラテジーパターンなどを利用してうまく実装すると良い。</p> <h2>注意点</h2><p>ここまで読めばわかる通り、SSR をしない Nuxt のサーバーで Nuxt コンテキストの情報を扱うのはかなり無理がある。<br>当たり前だが、ブラウザで表示されるタイトルはコンポーネントサイドで別途 <code class="codespan">useHead</code> などを利用して設定する必要がある。</p> <br><p>以下、この記事で紹介した手法を <code class="codespan">ssr-meta</code> 、Nuxt のコンポーネントによるタグ設定を CSR と呼ぶ。</p> <ul> <li><code class="codespan">ssr-meta</code> タグと CSR で設定したタグが異なる</li> </ul> <p>この場合は、ブラウザで JavaScript が実行された瞬間に CSR のタグで上書きされる。<br>可能な限り実装を共通化して、齟齬が生じないようにする必要がある。</p> <ul> <li><code class="codespan">ssr-meta</code> タグがあるページから SPA 遷移をした場合、遷移先に CSR タグがない場合はタイトルが変わらない</li> </ul> <p><code class="codespan">ssr-meta</code> によって HTML にタグを埋め込んだ場合、上書きされるか明示的に消すまで残り続ける。<br>CSR でタグを設定しないページが存在する場合は注意が必要である。</p> <h2>まとめ</h2><p>あまりおすすめできる手法ではないが、実現は可能ということで記しておく。<br><a href=https://nitro.unjs.io/config#routerules target="_blank" class="link">routeRules</a> を使って部分的に CSR と SSR を使い分けることもできるので、とりあえず全体を SSR 可能なコードで作っておいて、必要な場所だけ SSR していくというのが本当は良さそう。</p> </div></article><div class="_ads-row_1wnxa_11"><ins class="adsbygoogle" style="display:block;" data-ad-client="ca-pub-4629666904154057" data-ad-slot="8510217230" data-ad-format="auto" data-full-width-responsive="true"></ins><ins class="adsbygoogle" style="display:block;" data-ad-client="ca-pub-4629666904154057" data-ad-slot="2280669044" data-ad-format="auto" data-full-width-responsive="true"></ins></div><div class="_twitter-row_1wnxa_5"><a class="button" href="https://twitter.com/intent/tweet?url=https://ymmooot.dev/posts/s3orj0yq585j/&text=SSR%20%E3%81%97%E3%81%A6%E3%81%AA%E3%81%84%20Nuxt%20%E3%81%A7%20%3Ctitle%3E%20%E3%81%A8%20%3Cmeta%3E%20%E3%82%BF%E3%82%B0%E3%81%A0%E3%81%91%20SSR%20%E3%81%99%E3%82%8B%20%7C%20ymmooot%20blog" target="_blank" rel="noopener noreferrer" data-v-2e8f17a7> Share on <span class="logo" data-v-2e8f17a7>𝕏</span></a></div></main><footer class="footer _footer_csrqx_14" data-v-9674cd22><ul class="footer__items" data-v-9674cd22><!--[--><li class="footer__item" data-v-9674cd22><a href="https://github.com/ymmooot" target="_blank" rel="noopener" data-v-9674cd22>GitHub</a></li><li class="footer__item" data-v-9674cd22><a href="https://x.com/ymmooot" target="_blank" rel="noopener" data-v-9674cd22>X</a></li><li class="footer__item" data-v-9674cd22><a href="https://sizu.me/ymmooot" target="_blank" rel="noopener" data-v-9674cd22>しずかなインターネット</a></li><li class="footer__item" data-v-9674cd22><a href="https://www.flickr.com/photos/ymmooot/" target="_blank" rel="noopener" data-v-9674cd22>Flickr</a></li><!--]--></ul><div class="footer__version" data-v-9674cd22>Version: 3f1e8fc</div><ul class="footer__buttons" data-v-9674cd22><span data-v-9674cd22></span></ul></footer></div></div></div><div id="teleports"></div><script>window.__NUXT__={};window.__NUXT__.config={public:{gitHash:"3f1e8fc",gtag:{enabled:true,initMode:"auto",id:"G-9Y50CTC126",initCommands:[],config:{},tags:[],loadingStrategy:"defer",url:"https://www.googletagmanager.com/gtag/js"}},app:{baseURL:"/",buildId:"b95e544f-6915-449d-83a2-4650e893bff5",buildAssetsDir:"/_nuxt/",cdnURL:""}}</script><script type="application/json" data-nuxt-data="nuxt-app" data-ssr="true" id="__NUXT_DATA__" data-src="/posts/s3orj0yq585j/_payload.json?b95e544f-6915-449d-83a2-4650e893bff5">[{"state":1,"once":3,"_errors":4,"serverRendered":6,"path":7,"prerenderedAt":8},["Reactive",2],{},["Set"],["ShallowReactive",5],{},true,"\u002Fposts\u002Fs3orj0yq585j\u002F",1784522846160]</script></body></html>