diff --git a/docs/basic-features/eslint.md b/docs/basic-features/eslint.md
index a08fdc7e78821..dc68f5faed35c 100644
--- a/docs/basic-features/eslint.md
+++ b/docs/basic-features/eslint.md
@@ -95,6 +95,7 @@ Next.js provides an ESLint plugin, [`eslint-plugin-next`](https://www.npmjs.com/
| ✔️ | [next/no-title-in-document-head](https://nextjs.org/docs/messages/no-title-in-document-head) | Disallow using <title> with Head from next/document |
| ✔️ | [next/no-unwanted-polyfillio](https://nextjs.org/docs/messages/no-unwanted-polyfillio) | Prevent duplicate polyfills from Polyfill.io |
| ✔️ | next/no-typos | Ensure no typos were made declaring [Next.js's data fetching function](https://nextjs.org/docs/basic-features/data-fetching) |
+| ✔️ | [next/next-script-for-ga](https://nextjs.org/docs/messages/next-script-for-ga) | Use the Script component to defer loading of the script until necessary. |
- ✔: Enabled in the recommended configuration
diff --git a/errors/next-script-for-ga.md b/errors/next-script-for-ga.md
new file mode 100644
index 0000000000000..15d400c098bd5
--- /dev/null
+++ b/errors/next-script-for-ga.md
@@ -0,0 +1,98 @@
+# Next Script for Google Analytics
+
+### Why This Error Occurred
+
+An inline script was used for Google analytics which might impact your webpage's performance.
+
+### Possible Ways to Fix It
+
+#### Using gtag.js
+
+If you are using the [gtag.js](https://developers.google.com/analytics/devguides/collection/gtagjs) script to add analytics, use the `next/script` component with the right loading strategy to defer loading of the script until necessary.
+
+```jsx
+import Script from 'next/script'
+
+const Home = () => {
+ return (
+
+
+
+
+
+ )
+}
+
+export default Home
+```
+
+#### Using analytics.js
+
+If you are using the [analytics.js](https://developers.google.com/analytics/devguides/collection/analyticsjs) script to add analytics:
+
+```jsx
+import Script from 'next/script'
+
+const Home = () => {
+ return (
+
+
+
+ )
+}
+
+export default Home
+```
+
+If you are using the [alternative async variant](https://developers.google.com/analytics/devguides/collection/analyticsjs#alternative_async_tag):
+
+```jsx
+import Script from 'next/script'
+
+const Home = () => {
+ return (
+
+
+
+
+ )
+}
+
+export default Home
+```
+
+### Useful Links
+
+- [Add analytics.js to Your Site](https://developers.google.com/analytics/devguides/collection/analyticsjs)
+- [Efficiently load third-party JavaScript](https://web.dev/efficiently-load-third-party-javascript/)
+- [next/script Documentation](https://nextjs.org/docs/basic-features/script)
diff --git a/packages/eslint-plugin-next/lib/index.js b/packages/eslint-plugin-next/lib/index.js
index 59e12b7f43dfd..ae474796056ce 100644
--- a/packages/eslint-plugin-next/lib/index.js
+++ b/packages/eslint-plugin-next/lib/index.js
@@ -16,6 +16,7 @@ module.exports = {
'no-script-in-head': require('./rules/no-script-in-head'),
'no-typos': require('./rules/no-typos'),
'no-duplicate-head': require('./rules/no-duplicate-head'),
+ 'next-script-for-ga': require('./rules/next-script-for-ga'),
},
configs: {
recommended: {
@@ -31,6 +32,7 @@ module.exports = {
'@next/next/google-font-display': 1,
'@next/next/google-font-preconnect': 1,
'@next/next/link-passhref': 1,
+ '@next/next/next-script-for-ga': 1,
'@next/next/no-document-import-in-page': 2,
'@next/next/no-head-import-in-document': 2,
'@next/next/no-script-in-document': 2,
diff --git a/packages/eslint-plugin-next/lib/rules/next-script-for-ga.js b/packages/eslint-plugin-next/lib/rules/next-script-for-ga.js
new file mode 100644
index 0000000000000..09385460dcc8f
--- /dev/null
+++ b/packages/eslint-plugin-next/lib/rules/next-script-for-ga.js
@@ -0,0 +1,77 @@
+const NodeAttributes = require('../utils/node-attributes.js')
+
+const SUPPORTED_SRCS = [
+ 'www.google-analytics.com/analytics.js',
+ 'www.googletagmanager.com/gtag/js',
+]
+const SUPPORTED_HTML_CONTENT_URLS = [
+ 'www.google-analytics.com/analytics.js',
+ 'www.googletagmanager.com/gtm.js',
+]
+const ERROR_MSG =
+ 'Use the `next/script` component for loading third party scripts. See: https://nextjs.org/docs/messages/next-script-for-ga.'
+
+// Check if one of the items in the list is a substring of the passed string
+const containsStr = (str, strList) => {
+ return strList.some((s) => str.includes(s))
+}
+
+module.exports = {
+ meta: {
+ docs: {
+ description:
+ 'Prefer next script component when using the inline script for Google Analytics',
+ recommended: true,
+ },
+ },
+ create: function (context) {
+ return {
+ JSXOpeningElement(node) {
+ if (node.name.name !== 'script') {
+ return
+ }
+ if (node.attributes.length === 0) {
+ return
+ }
+ const attributes = new NodeAttributes(node)
+
+ // Check if the Alternative async tag is being used to add GA.
+ // https://developers.google.com/analytics/devguides/collection/analyticsjs#alternative_async_tag
+ // https://developers.google.com/analytics/devguides/collection/gtagjs
+ if (
+ typeof attributes.value('src') === 'string' &&
+ containsStr(attributes.value('src'), SUPPORTED_SRCS)
+ ) {
+ return context.report({
+ node,
+ message: ERROR_MSG,
+ })
+ }
+
+ // Check if inline script is being used to add GA.
+ // https://developers.google.com/analytics/devguides/collection/analyticsjs#the_google_analytics_tag
+ // https://developers.google.com/tag-manager/quickstart
+ if (
+ attributes.has('dangerouslySetInnerHTML') &&
+ attributes.value('dangerouslySetInnerHTML')[0]
+ ) {
+ const htmlContent =
+ attributes.value('dangerouslySetInnerHTML')[0].value.quasis &&
+ attributes.value('dangerouslySetInnerHTML')[0].value.quasis[0].value
+ .raw
+ if (
+ htmlContent &&
+ containsStr(htmlContent, SUPPORTED_HTML_CONTENT_URLS)
+ ) {
+ context.report({
+ node,
+ message: ERROR_MSG,
+ })
+ }
+ }
+ },
+ }
+ },
+}
+
+module.exports.schema = []
diff --git a/packages/eslint-plugin-next/lib/utils/node-attributes.js b/packages/eslint-plugin-next/lib/utils/node-attributes.js
index c9eb583275c8d..3367f31a26463 100644
--- a/packages/eslint-plugin-next/lib/utils/node-attributes.js
+++ b/packages/eslint-plugin-next/lib/utils/node-attributes.js
@@ -26,7 +26,9 @@ class NodeAttributes {
this.attributes[attribute.name.name].value = attribute.value.value
} else if (attribute.value.expression) {
this.attributes[attribute.name.name].value =
- attribute.value.expression.value
+ typeof attribute.value.expression.value !== 'undefined'
+ ? attribute.value.expression.value
+ : attribute.value.expression.properties
}
}
})
diff --git a/test/eslint-plugin-next/next-script-for-ga.unit.test.js b/test/eslint-plugin-next/next-script-for-ga.unit.test.js
new file mode 100644
index 0000000000000..18b156239c028
--- /dev/null
+++ b/test/eslint-plugin-next/next-script-for-ga.unit.test.js
@@ -0,0 +1,212 @@
+const rule = require('@next/eslint-plugin-next/lib/rules/next-script-for-ga')
+
+const RuleTester = require('eslint').RuleTester
+
+const ERROR_MSG =
+ 'Use the `next/script` component for loading third party scripts. See: https://nextjs.org/docs/messages/next-script-for-ga.'
+
+RuleTester.setDefaultConfig({
+ parserOptions: {
+ ecmaVersion: 2018,
+ sourceType: 'module',
+ ecmaFeatures: {
+ modules: true,
+ jsx: true,
+ },
+ },
+})
+
+var ruleTester = new RuleTester()
+ruleTester.run('sync-scripts', rule, {
+ valid: [
+ `import Script from 'next/script'
+
+ export class Blah extends Head {
+ render() {
+ return (
+
+
Hello title
+
+
+
+ );
+ }
+ }`,
+ `import Script from 'next/script'
+
+ export class Blah extends Head {
+ render() {
+ return (
+
+
Hello title
+
+
+ );
+ }
+ }`,
+ `import Script from 'next/script'
+
+ export class Blah extends Head {
+ render() {
+ return (
+
+
Hello title
+
+
+ );
+ }
+ }`,
+ `export class Blah extends Head {
+ render() {
+ return (
+
+
Hello title
+
+
+ );
+ }
+ }`,
+ ],
+
+ invalid: [
+ {
+ code: `
+ export class Blah extends Head {
+ render() {
+ return (
+
+
Hello title
+
+
+
+ );
+ }
+ }`,
+ errors: [
+ {
+ message: ERROR_MSG,
+ type: 'JSXOpeningElement',
+ },
+ ],
+ },
+ {
+ code: `
+ export class Blah extends Head {
+ render() {
+ return (
+
+
Hello title
qqq
+ {/* Google Tag Manager - Global base code */}
+
+
+ );
+ }
+ }`,
+ errors: [
+ {
+ message: ERROR_MSG,
+ type: 'JSXOpeningElement',
+ },
+ ],
+ },
+ {
+ code: `
+ export class Blah extends Head {
+ render() {
+ return (
+
+
Hello title
+
+
+ );
+ }
+ }`,
+ errors: [
+ {
+ message: ERROR_MSG,
+ type: 'JSXOpeningElement',
+ },
+ ],
+ },
+ {
+ code: `
+ export class Blah extends Head {
+ render() {
+ return (
+
+
Hello title
+
+
+
+ );
+ }
+ }`,
+ errors: [
+ {
+ message: ERROR_MSG,
+ type: 'JSXOpeningElement',
+ },
+ ],
+ },
+ ],
+})