Open9

Next.js script を head 内に出力したい問題

KiKiKi-KiKiKiKiKi-KiKi

Next.js v13 (page)

GTM (GA-4) のスクリプトを <head> 内に追加したい

GA-4 では <script> を head タグのなるべく上部に、<noscript><iframe /></noscript> を body タグの上部に置くようにと書かれている

Next のドキュメントに next/script を使った方法があるのでそれを参考にする

cf. https://nextjs.org/docs/messages/next-script-for-ga

next/script<Script> が出力されない問題

GTM 出力用のコンポーネント

// GTMScript.ts
import Script from 'next/script';
import { FC } from 'react';

export type GtmTrackingID = `GTM-${string}`;
type GTMScriptProps = {
  gtmID: GtmTrackingID;
};

export const GTMScript: FC<GTMScriptProps> = ({ gtmID }) => {
return (
    <Script id='googleTagManager'>
      {`
          (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
          new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
          j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
          'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
          })(window,document,'script','dataLayer','${gtmID}');
        `}
    </Script>
});

全ページ共通なので _document.tsx / _app.tsx で試してみたが <script> タグが消える

// /pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html lang='en'>
      <Head>
        <GTMScript gtmID={gtmID as GtmTrackingID} />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

=> <script> タグは出力されない

// /pages/_app.tsx
import Head from 'next/head';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Head>
        <GTMScript gtmID={gtmID as GtmTrackingID} />
      </Head>
      <Component {...pageProps} />
    </>
  );
}

=> <script> タグは出力されない

KiKiKi-KiKiKiKiKi-KiKi

<Head> 外にしてみる

_document.tsx => 🙅

// /pages/_document.tsx

export default function Document() {
  return (
    <Html lang='en'>
      <Head />
      <body>
        <GTMScript gtmID={gtmID as GtmTrackingID} />
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

=> <script> タグは出力されない

_app.tsx => 🙆

// /pages/_app.tsx

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <GTMScript gtmID={gtmID as GtmTrackingID} />
      <Component {...pageProps} />
    </>
  );
}

=> <body> の最下部に出力される

KiKiKi-KiKiKiKiKi-KiKi

<Script>strategy を変更してみる

_app.tsx では head 外に <Script> を置くと <script> タグが出力された
<script> タグに data-nscript="afterInteractive" という属性が付いていて、next/script の strategy で設定できるっぽい

strategy

The loading strategy of the script. There are four different strategies that can be used:

  • beforeInteractive: Load before any Next.js code and before any page hydration occurs.
  • afterInteractive: (default) Load early but after some hydration on the page occurs.
  • lazyOnload: Load during browser idle time.
  • worker: (experimental) Load in a web worker.

https://nextjs.org/docs/pages/api-reference/components/script#strategy

strategy="beforeInteractive" だと <head> 内に script タグが出力される

// GTMScript.ts
export const GTMScript: FC<GTMScriptProps> = ({ gtmID }) => {
  return (
-   <Script id='googleTagManager'>
+   <Script id='googleTagManager' strategy='beforeInteractive'>
      {`

/pages/_app.ts

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <GTMScript gtmID={gtmID as GtmTrackingID} />
      <Component {...pageProps} />
    </>
  );
}

=> head 内に <script> が出力される

※ ただし <Head> で追加した要素の後に追加される

// /pages/_app.ts
import Head from 'next/head';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Head>
        <meta name='next-head-meta' />
      </Head>
      <GTMScript gtmID={gtmID as GtmTrackingID} />
      <Component {...pageProps} />
    </>
  );
}

👇 next/head で追加した要素の後に strategy="beforeInteractive"<script> が追加される

KiKiKi-KiKiKiKiKi-KiKi

strategy="beforeInteractive" でも _document.tsx で読み込ませると出力されない

<Script id='googleTagManager' strategy='beforeInteractive'> な script を出力する GTMScript コンポーネントを _document.tsx で import しても🙅

// /pages/_document.tsx

export default function Document() {
  return (
    <Html lang='en'>
      <Head />
      <body>
        <GTMScript gtmID={gtmID as GtmTrackingID} />
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

=> <script> タグは出力されない

beforeInteractive な script を _doument.js で読み込ませるのは NG っぽい

ESLint の warning が出ていた

`next/script`'s `beforeInteractive` strategy should not be used outside of `pages/_document.js`. See: https://nextjs.org/docs/messages/no-before-interactive-script-outside-documenteslint@next/next/no-before-interactive-script-outside-document

You cannot use the next/script component with the beforeInteractive strategy outside pages/_document.js. That's because beforeInteractive strategy only works inside pages/_document.js and is designed to load scripts that are needed by the entire site (i.e. the script will load when any page in the application has been loaded server-side).

https://nextjs.org/docs/messages/no-before-interactive-script-outside-document

<Head> 内での使用するのは変わらず NG

// /pages/_app.tsx
import Head from 'next/head';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Head>
        <GTMScript gtmID={gtmID as GtmTrackingID} />
      </Head>
      <Component {...pageProps} />
    </>
  );
}

=> strategy="beforeInteractive" でも <Head> 内に配置すると <script> タグは出力されない

KiKiKi-KiKiKiKiKi-KiKi

next/script を使ったタグの出力

  • <Script>_document.tsx 内に配置しても script タグは表示されない
  • <Script>next/head, next/document 問わず <Head> 内に配置すると script タグは出力されない
  • _appt.tsx<Head> 外に置くと body の閉じタグ直前に script が出力される
  • strategy="beforeInteractive" オプションを与えた <Sctipt strategy="beforeInteractive"> なら <head> タグ内に script が出力されるが、<Head> コンポーネントで追加した要素の後に追加される

結論

strategy="beforeInteractive" オプションを使い、 _app.tsx で読み込ませると全てのページの <head> 内に script タグを出力できる

// GTMScript.ts
import Script from 'next/script';
import { FC } from 'react';

export type GtmTrackingID = `GTM-${string}`;
type GTMScriptProps = {
  gtmID: GtmTrackingID;
};

export const GTMScript: FC<GTMScriptProps> = ({ gtmID }) => {
return (
    <Script id='googleTagManager' strategy='beforeInteractive'>
      {`
          (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
          new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
          j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
          'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
          })(window,document,'script','dataLayer','${gtmID}');
        `}
    </Script>
});
// /pages/_app.tsx

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <GTMScript gtmID={gtmID as GtmTrackingID} />
      <Component {...pageProps} />
    </>
  );
}

※ ただし next/script<Script> コンポーネントを使用した場合、html の <head> の上部に script タグを出力するのは難しそう

KiKiKi-KiKiKiKiKi-KiKi

GA-4 の noscript

公式だと下記のような <noscript><body> タグ開始直後に置くように言われている

<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->

noscript は _document.tsx に書いても問題ない

通常のタグなので _document.tsx に置いても問題なく出力される
GTMScript.ts<noscript> を出力するコンポーネントを作成して読み込ませることにした

// GTMScript.ts
export type GtmTrackingID = `GTM-${string}`;
type GTMScriptProps = {
  gtmID: GtmTrackingID;
};
export const GTMNonScript: FC<GTMScriptProps> = ({ gtmID = defaultGtmID }) => {
  return (
    <noscript>
      <iframe
        src={`https://www.googletagmanager.com/ns.html?id=${gtmID}`}
        height='0'
        width='0'
        style={{ display: 'none', visibility: 'hidden' }}
      />
    </noscript>
  );
}

_document.tsx で読み込ませれば OK

// /pages/_document.tsx

export default function Document() {
  return (
    <Html lang='en'>
      <Head />
      <body>
        <GTMNonScript gtmID={gtmID as GtmTrackingID} />
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

noscript の中がエスケープされている

<noscript> の中が " で囲まれた文字列として出力されている

<noscript>"<iframe src="https://www.googletagmanager.com/ns.html?id=XXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe>"</noscript>

All noscript content is escaped when javascript is enabled. Try this with a <strong> tag and you'll see the same thing. However, when javascript is disabled, it works as expected.

https://stackoverflow.com/questions/57170187/noscripts-escapes-its-own-iframe-content

<noscript> タグは JavaScript が有効な時はエスケープされる仕様らしい

devtool で JS を切ってみるとエスケープされてない状態で出力された
devtool の メニュー から Run command を選択 Run > Disabled JavaScript を実行してリロード

JavaScript が off だと<noscript> の中がタグとして有効化されていた

ただし、
Next.js の場合 static generate してても JS が動作しない環境だと厳しいので、この設定自体なくても良い気もする

KiKiKi-KiKiKiKiKi-KiKi

直接 script タグを書けば Head で出力できる

あまりお行儀は良く無さそう & 直接 script を実行させるリスクはありそうだが、<Script> コンポーネントを用いなければ <Head> 内に直接 <script> を書いて出力させることができた

// /pages/_app.tsx
import Head from 'next/head';

export default function App({ Component, pageProps }: AppProps) {
  return (
   <>
      <Head>
        <script>{`
          console.log("foo");
        `}</script>
      <Head>
      <Component {...pageProps} />
    </>
  );
}

ただし、_app.tsx より内部のページコンポーネントの <Head> で指定した <title> の方が先に出力されている

_document.tsx<Head> (next/document) はエスケープされ next/head の後に出力される

// /pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html lang='en'>
      <Head>
        <script id='_document-head-script'>{`
          const bar = "xxx";
          console.log('bar');
        `}</script>
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

/pages/_document.tsx<Head> 内にも直接 script タグを書けば HTML の head 内に出力されたが、<script> タグの内部のコードの値がエスケープされており、console も出力されないのでスクリプトが壊れているように見える

また _app.tsx<Head> を使うより後に出力されるので、これなら先の <Script strategy='beforeInteractive'> を使えば良いので積極的に使用するメリットは無さそう

KiKiKi-KiKiKiKiKi-KiKi

直接記述した <script> の中身はエスケープされている?

// /components/SiteHead.tsx
export const SiteHead: FC = () => {
  return (
    <Head>
      <script id='site-head1'>{`console.log("site-head1")`}</script>
      <script
        id='site-head2'
        dangerouslySetInnerHTML={{ __html: 'console.log("site-head2")' }}
      ></script>
      <title>Next.app</title>
      <meta name='site head component' />
    </Head>
  );
};

site-head1 は直接 JS を記述し、site-head2dangerouslySetInnerHTML を使って JS そ挿入した

両方とも console.log も出力されて動作しているように見えるが、
Source で見てみると直接 JS を記述した site-head1 のコードはエスケープされており Uncaught SyntaxError: Unexpected token '&' というエラーも発生している

Script タグの位置が移動している

Source 上は SiteHead コンポーネントの通り <title> の上に <script> が表示されているが、dev tool で見ると script タグが SiteHead コンポーネント の <Head> の内容の最後に移動している

Next.js がレンダリング時に <script> タグを改めて追加し直しているのかもしれないが、原因は良く分からなかった。

conponent で <script> を返すものはうまく表示されない?

<script> タグを返す JSX を使えば GA-4 のスクリプトを <head> 上部に出力できるのでは?と思ったが、コンポーネントが返した<script> を dev tool 上で確認できず消えたように見える (Source 上は存在していて、console などは出力される)

// GTMScript.ts
export type GtmTrackingID = `GTM-${string}`;
type GTMScriptProps = {
  gtmID?: GtmTrackingID;
};

export const GTMScript2: FC<GTMScriptProps> = ({ gtmID = defaultGtmID }) => {
  return (
    <script
      id='gtm-script-2'
      dangerouslySetInnerHTML={{ __html: `console.log('${gtmID}')` }}
    ></script>
  );
};

dangerouslySetInnerHTML を使った <script> タグを返すコンポーネント

// SiteHead.tsx
export const SiteHead: FC = () => {
  return (
    <Head>
      <GTMScript2 gtmID='GTM-xxx' />
      <title>Next.app</title>
      <meta name='site head component' />
    </Head>
  );
};

connected 前に GTM-xxx が log に出力されているが、dev tool の <head> 内に script が見当たらない

Source 上だと SiteHead に記述した位置に <script> が出力されている

予想

<script> タグを直接記述する方法は Next がレンダリング時に <secript> タグを再配置しているのかもしれない
そう考えると Source 上に出力された状態で一度動作し、Next が再配置する際に消去されるとすれば説明がつく

KiKiKi-KiKiKiKiKi-KiKi

通常の <script> タグを直接 JSX に記述する

  • _document.tsx<Head> (next/document) に記述された <script> はエスケープされて動作しない
  • <Head> (nex/head) 無いに直接 <script> タグを記述した場合、そのまま <head> 内に出力される
    • 直接コードを記述する場合は dangerouslySetInnerHTML を使ったほうが良い
    • dangerouslySetInnerHTML を使わなくとも追加したコードは動作するが、エスケープされた <script> が source 上に出力されエラーが出たりする
  • _app.tsx<Head> より内部のページコンポーネントの <Head> の内容のほうが先に出力される
    • _document.tex<Head> の内容は _app.tsx のものより後になる
  • <script> を返すコンポーネントを <Head> 内においた場合
    • dangerouslySetInnerHTML でコードを追加したスクリプトは動作しているように見えるが、dev tool で <script> タグの存在を確認できない
    • dangerouslySetInnerHTML を使わない場合は、Source 上に <script> タグは確認できるが動作せず ( console などが出力されない)、dev tool 上でも <script> タグの存在を確認できない

結論

<script dangerouslySetInnerHTML={{__html: JS のコード}} />は動作するが、挙動が怪しいので<Script>` コンポーネントを使用したほうが安全だと感じた