├── .eslintrc.json
├── .github
├── dependabot.yml
└── workflows
│ ├── dependabot-auto-merge.yml
│ └── main.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── jest.config.js
├── package-lock.json
├── package.json
├── src
├── components
│ ├── GoogleAnalytics.test.tsx
│ ├── GoogleAnalytics.tsx
│ └── index.ts
├── hooks
│ ├── index.ts
│ ├── usePageViews.ts
│ └── usePagesViews.ts
├── index.ts
└── interactions
│ ├── consent.test.ts
│ ├── consent.ts
│ ├── event.test.ts
│ ├── event.ts
│ ├── index.ts
│ ├── pageView.test.ts
│ └── pageView.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "settings": {
7 | "react": {
8 | "version": "detect"
9 | }
10 | },
11 | "extends": [
12 | "eslint:recommended",
13 | "plugin:react/recommended",
14 | "plugin:@typescript-eslint/recommended",
15 | "prettier"
16 | ],
17 | "parser": "@typescript-eslint/parser",
18 | "parserOptions": {
19 | "ecmaFeatures": {
20 | "jsx": true
21 | },
22 | "ecmaVersion": 12,
23 | "sourceType": "module"
24 | },
25 | "plugins": ["react", "@typescript-eslint"],
26 | "rules": {}
27 | }
28 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
7 | commit-message:
8 | prefix: fix
9 | prefix-development: chore
10 | include: scope
11 |
--------------------------------------------------------------------------------
/.github/workflows/dependabot-auto-merge.yml:
--------------------------------------------------------------------------------
1 | name: Dependabot auto-merge
2 |
3 | on: pull_request_target
4 |
5 | permissions:
6 | pull-requests: write
7 | contents: write
8 |
9 | jobs:
10 | auto-merge-dependabot-pr:
11 | runs-on: ubuntu-latest
12 | if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }}
13 | steps:
14 | - name: Dependabot metadata
15 | id: dependabot-metadata
16 | uses: dependabot/fetch-metadata@v1.3.1
17 | - name: Enable auto-merge for patch and minor updates
18 | if: ${{steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch' || steps.dependabot-metadata.outputs.update-type == 'version-update:semver-minor'}}
19 | run: gh pr merge --auto --squash "$PR_URL"
20 | env:
21 | PR_URL: ${{github.event.pull_request.html_url}}
22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
23 | - name: Enable auto-merge for major updates of development dependencies
24 | if: ${{steps.dependabot-metadata.outputs.update-type == 'version-update:semver-major' && steps.dependabot-metadata.outputs.dependency-type == 'direct:development'}}
25 | run: gh pr merge --auto --squash "$PR_URL"
26 | env:
27 | PR_URL: ${{github.event.pull_request.html_url}}
28 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
29 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | jobs:
8 | build-and-test:
9 | name: Build, lint, and test
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: actions/setup-node@v4
14 | with:
15 | node-version: 20
16 | cache: "npm"
17 | - name: Install Dependencies
18 | run: npm ci
19 | - name: Lint
20 | run: npm run lint
21 | - name: Test
22 | run: npm run test -- --ci --coverage
23 | - name: Codecov
24 | run: npx codecov
25 | - name: Build
26 | run: npm run build
27 | - name: Upload build
28 | if: github.ref == 'refs/heads/main'
29 | uses: actions/upload-artifact@v4
30 | with:
31 | name: dist
32 | path: dist
33 | release:
34 | runs-on: ubuntu-latest
35 | needs: build-and-test
36 | if: github.ref == 'refs/heads/main'
37 | steps:
38 | - uses: actions/checkout@v4
39 | - uses: actions/setup-node@v4
40 | with:
41 | node-version: 20
42 | cache: "npm"
43 | - name: Install Dependencies
44 | run: npm ci
45 | - name: Download build
46 | uses: actions/download-artifact@v4
47 | with:
48 | name: dist
49 | - name: Release
50 | env:
51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
52 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
53 | run: npx semantic-release
54 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | coverage
4 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | .
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Mauricio Robayo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > [!NOTE]
2 | > You might not need this package. Please check [Third Party Libraries](https://nextjs.org/docs/app/building-your-application/optimizing/third-party-libraries) first.
3 |
4 | # Nextjs Google Analytics
5 |
6 | [](https://badge.fury.io/js/nextjs-google-analytics)
7 | [](https://codecov.io/gh/MauricioRobayo/nextjs-google-analytics)
8 | [](https://www.codefactor.io/repository/github/mauriciorobayo/nextjs-google-analytics)
9 |
10 | **Google Analytics for Next.js**
11 |
12 | This package optimizes script loading using [Next.js `Script` tag](https://nextjs.org/docs/basic-features/script), which means that it will **only work on apps using Next.js >= 11.0.0**.
13 |
14 | ## Installation
15 |
16 | ```
17 | npm install --save nextjs-google-analytics
18 | ```
19 |
20 | ## TL;DR
21 |
22 | Add the `GoogleAnalytics` component with the `trackPageViews` prop set to `true` to your [custom App](https://nextjs.org/docs/advanced-features/custom-app) file:
23 |
24 | ```js
25 | // pages/_app.js
26 | import { GoogleAnalytics } from "nextjs-google-analytics";
27 |
28 | const App = ({ Component, pageProps }) => {
29 | return (
30 | <>
31 |
32 |
33 | >
34 | );
35 | };
36 |
37 | export default App;
38 | ```
39 |
40 | You can pass your _Google Analytics measurement id_ by setting it on the `NEXT_PUBLIC_GA_MEASUREMENT_ID` environment variable or using the `gaMeasurementId` prop on the `GoogleAnalytics` component. **The environment variable will override the prop if both are set**.
41 |
42 | ## Usage
43 |
44 | Your _Google Analytics measurement id_ is read from `NEXT_PUBLIC_GA_MEASUREMENT_ID` environment variable, so make sure it is set in your production environment:
45 |
46 | - [Vercel](https://vercel.com/docs/environment-variables)
47 | - [Netlify](https://www.netlify.com/blog/2020/12/10/environment-variables-in-next.js-and-netlify/)
48 |
49 | If the variable is not set or is empty, nothing will be loaded, making it safe to work in development.
50 |
51 | To load it and test it on development, add:
52 |
53 | ```
54 | NEXT_PUBLIC_GA_MEASUREMENT_ID="G-XXXXXXXXXX"
55 | ```
56 |
57 | to your `.env.local` file.
58 |
59 | As an alternative, you can use the `gaMeasurementId` param to pass your _Google Analytics measurement id_.
60 |
61 | The `NEXT_PUBLIC_GA_MEASUREMENT_ID` environment variable will take precedence over the `gaMeasurementId` param, so if both are set with different values, the environment variable will override the param.
62 |
63 | ## Scripts
64 |
65 | Use the `GoogleAnalytics` component to load the gtag scripts. You can add it to a [custom App](https://nextjs.org/docs/advanced-features/custom-app) component and this will take care of including the necessary scripts for every page (or you could add it on a per page basis if you need more control):
66 |
67 | ```js
68 | // pages/_app.js
69 | import { GoogleAnalytics } from "nextjs-google-analytics";
70 |
71 | const App = ({ Component, pageProps }) => {
72 | return (
73 | <>
74 |
75 |
76 | >
77 | );
78 | };
79 |
80 | export default App;
81 | ```
82 |
83 | By default, scripts are loaded using the `afterInteractive` strategy, which means they are injected on the client-side and will run after hydration.
84 |
85 | If you need more control, the component exposes the [strategy](https://nextjs.org/docs/basic-features/script) prop to control how the scripts are loaded:
86 |
87 | ```js
88 | // pages/_app.js
89 | import { GoogleAnalytics } from "nextjs-google-analytics";
90 |
91 | const App = ({ Component, pageProps }) => {
92 | return (
93 | <>
94 |
95 |
96 | >
97 | );
98 | };
99 |
100 | export default App;
101 | ```
102 | also, you can use alternative to default path for googletagmanager script by
103 | ```js
104 |
105 | ```
106 |
107 | ## Page views
108 |
109 | To track page views set the `trackPageViews` prop of the `GoogleAnalytics` component to true.
110 |
111 | ```js
112 | // pages/_app.js
113 | import { GoogleAnalytics } from "nextjs-google-analytics";
114 |
115 | const App = ({ Component, pageProps }) => {
116 | return (
117 | <>
118 |
119 |
120 | >
121 | );
122 | };
123 |
124 | export default App;
125 | ```
126 |
127 | By default it will be trigger on hash changes if `trackPageViews` is enabled, but you can ignore hash changes by providing an object to the `trackPageViews` prop:
128 |
129 | ```js
130 | // pages/_app.js
131 | import { GoogleAnalytics } from "nextjs-google-analytics";
132 |
133 | const App = ({ Component, pageProps }) => {
134 | return (
135 | <>
136 |
137 |
138 | >
139 | );
140 | };
141 |
142 | export default App;
143 | ```
144 |
145 | As an alternative, you can directly call the `usePageViews` hook inside a [custom App](https://nextjs.org/docs/advanced-features/custom-app) component, **do not set `trackPageViews` prop on the `GoogleAnalytics` component** or set it to false (default):
146 |
147 | ```js
148 | // pages/_app.js
149 | import { GoogleAnalytics, usePageViews } from "nextjs-google-analytics";
150 |
151 | const App = ({ Component, pageProps }) => {
152 | usePageViews(); // IgnoreHashChange defaults to false
153 | // usePageViews({ ignoreHashChange: true });
154 |
155 | return (
156 | <>
157 | {/* or */}
158 |
159 | >
160 | );
161 | };
162 |
163 | export default App;
164 | ```
165 |
166 | The module also exports a `pageView` function that you can use if you need more control.
167 |
168 | ## Custom event
169 |
170 | You can use the `event` function to track a custom event:
171 |
172 | ```js
173 | import { useState } from "react";
174 | import Page from "../components/Page";
175 | import { event } from "nextjs-google-analytics";
176 |
177 | export function Contact() {
178 | const [message, setMessage] = useState("");
179 |
180 | const handleInput = (e) => {
181 | setMessage(e.target.value);
182 | };
183 |
184 | const handleSubmit = (e) => {
185 | e.preventDefault();
186 |
187 | event("submit_form", {
188 | category: "Contact",
189 | label: message,
190 | });
191 |
192 | setState("");
193 | };
194 |
195 | return (
196 |
197 | This is the Contact page
198 |
205 |
206 | );
207 | }
208 | ```
209 |
210 | For the possible parameters that can be specified in the `event`, please refer to the `event` command in the Google tag API reference.
211 |
212 | - [Google tag API reference - event](https://developers.google.com/tag-platform/gtagjs/reference#event)
213 |
214 | ## Consent
215 |
216 | You can use the `consent` function to update your users' cookie preferences (GDPR).
217 |
218 | ```js
219 | const consentValue: 'denied' | 'granted' = getUserCookiePreferenceFromLocalStorage(); // 'denied' or 'granted'
220 |
221 | consent({
222 | arg: 'update',
223 | params: {
224 | ad_storage: consentValue,
225 | analytics_storage: consentValue,
226 | ad_user_data: consentValue,
227 | ad_personalization: consentValue
228 | },
229 | });
230 | ```
231 |
232 | For the possible values that can be specified in `arg` and `params`, please refer to the `consent` command in the Google tag API reference.
233 |
234 | - [Google tag API reference - consent](https://developers.google.com/tag-platform/gtagjs/reference#consent)
235 |
236 | ## Web Vitals
237 |
238 | To send [Next.js web vitals](https://nextjs.org/docs/advanced-features/measuring-performance#sending-results-to-analytics) to Google Analytics you can use a custom event on the `reportWebVitals` function inside a [custom App](https://nextjs.org/docs/advanced-features/custom-app) component:
239 |
240 | ```js
241 | // pages/_app.js
242 | import { GoogleAnalytics, event } from "nextjs-google-analytics";
243 |
244 | export function reportWebVitals({ id, name, label, value }) {
245 | event(name, {
246 | category: label === "web-vital" ? "Web Vitals" : "Next.js custom metric",
247 | value: Math.round(name === "CLS" ? value * 1000 : value), // values must be integers
248 | label: id, // id unique to current page load
249 | nonInteraction: true, // avoids affecting bounce rate.
250 | });
251 | }
252 |
253 | const App = ({ Component, pageProps }) => {
254 | return (
255 | <>
256 |
257 |
258 | >
259 | );
260 | };
261 |
262 | export default App;
263 | ```
264 |
265 | If you are using TypeScript, you can import `NextWebVitalsMetric` from `next/app`:
266 |
267 | ```ts
268 | import type { NextWebVitalsMetric } from "next/app";
269 |
270 | export function reportWebVitals(metric: NextWebVitalsMetric) {
271 | // ...
272 | }
273 | ```
274 |
275 | ## Using the `gaMeasurementId` param
276 |
277 | All exported components, hooks, and functions, accept an optional `gaMeasurementId` param that can be used in case no environment variable is provided:
278 |
279 | ```js
280 | // pages/_app.js
281 | import { GoogleAnalytics, event } from "nextjs-google-analytics";
282 | import { gaMeasurementId } from "./lib/gtag";
283 |
284 | export function reportWebVitals({
285 | id,
286 | name,
287 | label,
288 | value,
289 | }: NextWebVitalsMetric) {
290 | event(
291 | name,
292 | {
293 | category: label === "web-vital" ? "Web Vitals" : "Next.js custom metric",
294 | value: Math.round(name === "CLS" ? value * 1000 : value), // values must be integers
295 | label: id, // id unique to current page load
296 | nonInteraction: true, // avoids affecting bounce rate.
297 | },
298 | gaMeasurementId
299 | );
300 | }
301 | const App = ({ Component, pageProps }) => {
302 | usePageViews({ gaMeasurementId });
303 |
304 | return (
305 | <>
306 |
307 |
308 | >
309 | );
310 | };
311 |
312 | export default App;
313 | ```
314 |
315 | ## Debugging you Google Analytics
316 |
317 | 1. Install the [Google Analytics Debugger](https://chrome.google.com/webstore/detail/google-analytics-debugger/jnkmfdileelhofjcijamephohjechhna).
318 | 2. Turn it on by clicking its icon to the right of the address bar.
319 | 3. Open the Chrome Javascript console to see the messages.
320 |
321 | On Windows and Linux, press Control-Shift-J.
322 |
323 | On Mac, press Command-Option-J.
324 |
325 | 4. Refresh the page you are on.
326 |
327 | ## TypeScript
328 |
329 | The module is written in TypeScript and type definitions are included.
330 |
331 | ## Contributing
332 |
333 | Contributions, issues and feature requests are welcome!
334 |
335 | ## Show your support
336 |
337 | Give a ⭐️ if you like this project!
338 |
339 | ## LICENSE
340 |
341 | [MIT](./LICENSE)
342 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: "ts-jest",
4 | testEnvironment: "jsdom",
5 | };
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-google-analytics",
3 | "version": "0.0.0",
4 | "description": "Google Analytics for Next.js",
5 | "main": "dist/index.js",
6 | "sideEffects": false,
7 | "scripts": {
8 | "lint": "eslint src --fix",
9 | "build": "del-cli dist && tsc",
10 | "watch": "tsc-watch --onSuccess \"yalc push\"",
11 | "prepublishOnly": "npm run build",
12 | "test": "jest"
13 | },
14 | "keywords": [
15 | "gtag",
16 | "google analytics",
17 | "next.js",
18 | "analytics",
19 | "google tag manager",
20 | "gtm",
21 | "ga"
22 | ],
23 | "author": "Mauricio Robayo",
24 | "license": "MIT",
25 | "devDependencies": {
26 | "@testing-library/react": "^16.0.0",
27 | "@types/gtag.js": "^0.0.20",
28 | "@types/jest": "^27.4.1",
29 | "@types/react": "^18.0.8",
30 | "@types/react-test-renderer": "^18.0.0",
31 | "@typescript-eslint/eslint-plugin": "^5.21.0",
32 | "@typescript-eslint/parser": "^5.21.0",
33 | "del-cli": "^5.1.0",
34 | "eslint": "^8.14.0",
35 | "eslint-config-prettier": "^9.0.0",
36 | "eslint-plugin-react": "^7.29.4",
37 | "jest": "^27.5.1",
38 | "next": "^14.0.0",
39 | "prettier": "^3.0.0",
40 | "react": "^18.1.0",
41 | "react-test-renderer": "^18.1.0",
42 | "ts-jest": "^27.1.4",
43 | "ts-lib": "^0.0.5",
44 | "tsc-watch": "^6.0.0",
45 | "typescript": "^4.6.4"
46 | },
47 | "peerDependencies": {
48 | "next": ">=11.0.0",
49 | "react": ">=17.0.0"
50 | },
51 | "files": [
52 | "dist"
53 | ],
54 | "repository": {
55 | "type": "git",
56 | "url": "https://github.com/MauricioRobayo/nextjs-google-analytics.git"
57 | },
58 | "bugs": {
59 | "url": "https://github.com/MauricioRobayo/nextjs-google-analytics/issues"
60 | },
61 | "homepage": "https://github.com/MauricioRobayo/nextjs-google-analytics#readme",
62 | "optionalDependencies": {
63 | "fsevents": "^2.3.2"
64 | },
65 | "release": {
66 | "branches": "main"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/GoogleAnalytics.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, screen } from "@testing-library/react";
3 | import { GoogleAnalytics } from "./GoogleAnalytics";
4 | import { Router } from "next/router";
5 | import * as hooks from "../hooks";
6 |
7 | jest.mock("next/router", () => {
8 | return {
9 | ...jest.requireActual("next/router"),
10 | Router: {
11 | events: {
12 | on: jest.fn(),
13 | off: () => null,
14 | },
15 | },
16 | };
17 | });
18 |
19 | jest.mock(
20 | "next/script",
21 | () =>
22 | function MockScript(props: React.HTMLAttributes) {
23 | return ;
24 | }
25 | );
26 |
27 | afterEach(() => {
28 | jest.clearAllMocks();
29 | });
30 |
31 | describe("GoogleAnalytics", () => {
32 | const usePageViewsSpy = jest.spyOn(hooks, "usePageViews");
33 |
34 | it("should disable usePageViews if trackPageViews not set", () => {
35 | render();
36 | expect(usePageViewsSpy).toBeCalledWith({
37 | disabled: true,
38 | gaMeasurementId: undefined,
39 | ignoreHashChange: false,
40 | });
41 | expect(Router.events.on).not.toBeCalled();
42 | });
43 |
44 | it("should disable usePageViews if trackPageViews is set to false", () => {
45 | render();
46 | expect(usePageViewsSpy).toBeCalledWith({
47 | disabled: true,
48 | gaMeasurementId: undefined,
49 | ignoreHashChange: false,
50 | });
51 | expect(Router.events.on).not.toBeCalled();
52 | });
53 |
54 | it("should call usePageViews with gaMeasurementId", () => {
55 | render();
56 | expect(usePageViewsSpy).toBeCalledWith({
57 | disabled: true,
58 | gaMeasurementId: "1234",
59 | ignoreHashChange: false,
60 | });
61 | expect(Router.events.on).not.toBeCalled();
62 | });
63 |
64 | it("should enable usePageViews if trackPageViews is set", () => {
65 | render();
66 | expect(usePageViewsSpy).toBeCalledWith({
67 | disabled: false,
68 | gaMeasurementId: undefined,
69 | ignoreHashChange: false,
70 | });
71 | expect(Router.events.on).toBeCalled();
72 | });
73 |
74 | it("should enable usePageViews and ignoreHashChange", () => {
75 | render();
76 | expect(usePageViewsSpy).toBeCalledWith({
77 | disabled: false,
78 | gaMeasurementId: undefined,
79 | ignoreHashChange: true,
80 | });
81 | expect(Router.events.on).toBeCalled();
82 | });
83 |
84 | it("should enable usePageViews and ignoreHashChange with gaMeasurementId", () => {
85 | render(
86 |
90 | );
91 | expect(usePageViewsSpy).toBeCalledWith({
92 | disabled: false,
93 | gaMeasurementId: "1234",
94 | ignoreHashChange: false,
95 | });
96 | expect(Router.events.on).toBeCalled();
97 | });
98 |
99 | it("should enable usePageViews and ignoreHashChange with gaMeasurementId from env", () => {
100 | process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID = "1234";
101 | render();
102 | expect(usePageViewsSpy).toBeCalledWith({
103 | disabled: false,
104 | gaMeasurementId: "1234",
105 | ignoreHashChange: false,
106 | });
107 | expect(Router.events.on).toBeCalled();
108 | });
109 |
110 | it("should override param if env is used", () => {
111 | process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID = "1234";
112 | render(
113 |
117 | );
118 | expect(usePageViewsSpy).toBeCalledWith({
119 | disabled: false,
120 | gaMeasurementId: "1234",
121 | ignoreHashChange: false,
122 | });
123 | expect(Router.events.on).toBeCalled();
124 | });
125 |
126 | describe("debugMode", () => {
127 | it("should not have debug_mode when the debugMode prop is not set", () => {
128 | render();
129 | expect(screen.queryByText(/debug_mode:/)).toBeNull();
130 | });
131 |
132 | it("should have a debug_mode when the debugMode prop is set", () => {
133 | render();
134 | expect(screen.queryByText(/debug_mode:/)).not.toBeNull();
135 | });
136 | });
137 |
138 | describe("defaultConsent", () => {
139 | it("should have consent explicitly denied when defaultConsent is set to 'denied'", () => {
140 | render();
141 | expect(screen.queryByText(/'ad_storage': 'denied'/)).not.toBeNull();
142 | expect(screen.queryByText(/'analytics_storage': 'denied'/)).not.toBeNull();
143 | expect(screen.queryByText(/'ad_user_data': 'denied'/)).not.toBeNull();
144 | expect(screen.queryByText(/'ad_personalization': 'denied'/)).not.toBeNull();
145 | });
146 |
147 | it("should not call consent function at all when defaultConsent is set to 'granted'", () => {
148 | render();
149 | expect(screen.queryByText(/'consent', 'default'/)).toBeNull();
150 | });
151 |
152 | it("should not call consent function at all when defaultConsent is omitted", () => {
153 | render();
154 | expect(screen.queryByText(/'consent', 'default'/)).toBeNull();
155 | });
156 | });
157 | });
158 |
--------------------------------------------------------------------------------
/src/components/GoogleAnalytics.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Script, { ScriptProps } from "next/script";
3 | import { usePageViews } from "../hooks";
4 |
5 | type GoogleAnalyticsProps = {
6 | gaMeasurementId?: string;
7 | gtagUrl?: string;
8 | strategy?: ScriptProps["strategy"];
9 | debugMode?: boolean;
10 | defaultConsent?: "granted" | "denied";
11 | nonce?: string;
12 | };
13 |
14 | type WithPageView = GoogleAnalyticsProps & {
15 | trackPageViews?: boolean;
16 | };
17 |
18 | type WithIgnoreHashChange = GoogleAnalyticsProps & {
19 | trackPageViews?: {
20 | ignoreHashChange: boolean;
21 | };
22 | };
23 |
24 | export function GoogleAnalytics({
25 | debugMode = false,
26 | gaMeasurementId,
27 | gtagUrl = "https://www.googletagmanager.com/gtag/js",
28 | strategy = "afterInteractive",
29 | defaultConsent = "granted",
30 | trackPageViews,
31 | nonce,
32 | }: WithPageView | WithIgnoreHashChange): JSX.Element | null {
33 | const _gaMeasurementId =
34 | process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID ?? gaMeasurementId;
35 |
36 | usePageViews({
37 | gaMeasurementId: _gaMeasurementId,
38 | ignoreHashChange:
39 | typeof trackPageViews === "object"
40 | ? trackPageViews?.ignoreHashChange
41 | : false,
42 | disabled: !trackPageViews,
43 | });
44 |
45 | if (!_gaMeasurementId) {
46 | return null;
47 | }
48 |
49 | return (
50 | <>
51 |
52 |
72 | >
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { GoogleAnalytics } from "./GoogleAnalytics";
2 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { usePagesViews } from "./usePagesViews";
2 | export { usePageViews, UsePageViewsOptions } from "./usePageViews";
3 |
--------------------------------------------------------------------------------
/src/hooks/usePageViews.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { Router } from "next/router";
3 | import { pageView } from "../interactions";
4 |
5 | export interface UsePageViewsOptions {
6 | gaMeasurementId?: string;
7 | ignoreHashChange?: boolean;
8 | disabled?: boolean;
9 | }
10 |
11 | export function usePageViews({
12 | gaMeasurementId,
13 | ignoreHashChange,
14 | disabled,
15 | }: UsePageViewsOptions = {}): void {
16 | useEffect(() => {
17 | if (disabled) {
18 | return;
19 | }
20 |
21 | const handleRouteChange = (url: URL): void => {
22 | const _gaMeasurementId =
23 | process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID ?? gaMeasurementId;
24 |
25 | pageView({ path: url.toString() }, _gaMeasurementId);
26 | };
27 |
28 | Router.events.on("routeChangeComplete", handleRouteChange);
29 |
30 | if (!ignoreHashChange) {
31 | Router.events.on("hashChangeComplete", handleRouteChange);
32 | }
33 |
34 | return () => {
35 | Router.events.off("routeChangeComplete", handleRouteChange);
36 |
37 | if (!ignoreHashChange) {
38 | Router.events.off("hashChangeComplete", handleRouteChange);
39 | }
40 | };
41 | }, [Router.events, gaMeasurementId, ignoreHashChange]);
42 | }
43 |
--------------------------------------------------------------------------------
/src/hooks/usePagesViews.ts:
--------------------------------------------------------------------------------
1 | import { usePageViews, UsePageViewsOptions } from "./usePageViews";
2 |
3 | /**
4 | *
5 | * @deprecated Use usePageViews instead
6 | */
7 | export function usePagesViews(options?: UsePageViewsOptions): void {
8 | console.warn(
9 | "Nextjs Google Analytics: The 'usePagesViews' hook is deprecated. Please use 'usePageViews' hook instead. https://github.com/MauricioRobayo/nextjs-google-analytics#readme"
10 | );
11 | usePageViews(options);
12 | }
13 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { GoogleAnalytics } from "./components";
2 | export { usePagesViews, usePageViews, UsePageViewsOptions } from "./hooks";
3 | export { pageView, event, consent } from "./interactions";
4 |
--------------------------------------------------------------------------------
/src/interactions/consent.test.ts:
--------------------------------------------------------------------------------
1 | import { consent } from './consent'
2 |
3 | const mockArg: Gtag.ConsentArg = 'default'
4 | const mockParams: Gtag.ConsentParams = { ad_storage: 'denied', analytics_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' }
5 |
6 | describe("consent", () => {
7 | it("should not throw an error if gtag is not defined", () => {
8 | const action = () => consent({ arg: 'default', params: {} });
9 | expect(action).not.toThrow();
10 | });
11 |
12 | it("should call gtag with all the options", () => {
13 | window.gtag = jest.fn();
14 | consent({ arg: mockArg, params: mockParams });
15 | expect(window.gtag).toBeCalledTimes(1);
16 | expect(window.gtag).toBeCalledWith('consent', mockArg, mockParams);
17 | });
18 | })
19 |
20 |
--------------------------------------------------------------------------------
/src/interactions/consent.ts:
--------------------------------------------------------------------------------
1 | // https://developers.google.com/tag-platform/devguides/consent#gtag.js
2 |
3 | type ConsentOptions = {
4 | arg: Gtag.ConsentArg;
5 | params: Gtag.ConsentParams;
6 | }
7 |
8 | export function consent({ arg, params }: ConsentOptions): void {
9 | if (!window.gtag) {
10 | return;
11 | }
12 | window.gtag('consent', arg, params);
13 | }
14 |
--------------------------------------------------------------------------------
/src/interactions/event.test.ts:
--------------------------------------------------------------------------------
1 | import { event } from "./event";
2 |
3 | const OLD_ENV = process.env;
4 |
5 | beforeEach(() => {
6 | console.warn = jest.fn();
7 | window.gtag = jest.fn();
8 | jest.resetModules();
9 | jest.clearAllMocks();
10 | jest.resetAllMocks();
11 | process.env = { ...OLD_ENV };
12 | });
13 |
14 | afterAll(() => {
15 | process.env = OLD_ENV;
16 | });
17 |
18 | const mockEvent = "mock event";
19 | const mockCategory = "mock category";
20 | const mockLabel = "mock label";
21 | const mockValue = 1;
22 | const mockNonInteraction = true;
23 | const mockUserId = "mock user id";
24 |
25 | describe("options", () => {
26 | it("should call gtag with all the options", () => {
27 | event(mockEvent, {
28 | category: mockCategory,
29 | label: mockLabel,
30 | value: mockValue,
31 | nonInteraction: mockNonInteraction,
32 | userId: mockUserId,
33 | });
34 |
35 | expect(window.gtag).toBeCalledTimes(1);
36 | expect(window.gtag).toHaveBeenCalledWith("event", mockEvent, {
37 | event_category: mockCategory,
38 | event_label: mockLabel,
39 | value: mockValue,
40 | non_interaction: mockNonInteraction,
41 | user_id: mockUserId,
42 | });
43 | });
44 |
45 | it("should call gtag with {} when no options given", () => {
46 | event(mockEvent);
47 |
48 | expect(window.gtag).toBeCalledTimes(1);
49 | expect(window.gtag).toHaveBeenCalledWith("event", mockEvent, {});
50 | });
51 |
52 | it("should call gtag with event_category when category given", () => {
53 | event(mockEvent, {
54 | category: mockCategory,
55 | });
56 |
57 | expect(window.gtag).toBeCalledTimes(1);
58 | expect(window.gtag).toHaveBeenCalledWith("event", mockEvent, {
59 | event_category: mockCategory,
60 | });
61 | });
62 |
63 | it("should call gtag with event_label when event_label given", () => {
64 | event(mockEvent, {
65 | label: mockLabel,
66 | });
67 |
68 | expect(window.gtag).toBeCalledTimes(1);
69 | expect(window.gtag).toHaveBeenCalledWith("event", mockEvent, {
70 | event_label: mockLabel,
71 | });
72 | });
73 |
74 | it("should call gtag with value when value given", () => {
75 | event(mockEvent, {
76 | value: mockValue,
77 | });
78 |
79 | expect(window.gtag).toBeCalledTimes(1);
80 | expect(window.gtag).toHaveBeenCalledWith("event", mockEvent, {
81 | value: mockValue,
82 | });
83 | });
84 |
85 | it("should call gtag with user_id when user_id given", () => {
86 | event(mockEvent, {
87 | userId: mockUserId,
88 | });
89 |
90 | expect(window.gtag).toBeCalledTimes(1);
91 | expect(window.gtag).toHaveBeenCalledWith("event", mockEvent, {
92 | user_id: mockUserId,
93 | });
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/src/interactions/event.ts:
--------------------------------------------------------------------------------
1 | // https://developers.google.com/analytics/devguides/collection/gtagjs/events
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
4 | type EventOptions = Record & {
5 | category?: string;
6 | label?: string;
7 | value?: number;
8 | nonInteraction?: boolean;
9 | userId?: string;
10 | };
11 |
12 | export function event(
13 | action: string,
14 | { category, label, value, nonInteraction, userId, ...otherOptions }: EventOptions = {},
15 | ): void {
16 | if (!window.gtag) {
17 | return;
18 | }
19 |
20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
21 | const eventOptions: Record & {
22 | event_category?: string;
23 | event_label?: string;
24 | value?: number;
25 | non_interaction?: boolean;
26 | user_id?: string;
27 | } = { ...otherOptions };
28 |
29 | if (category !== undefined) {
30 | eventOptions.event_category = category;
31 | }
32 |
33 | if (label !== undefined) {
34 | eventOptions.event_label = label;
35 | }
36 |
37 | if (value !== undefined) {
38 | eventOptions.value = value;
39 | }
40 |
41 | if (nonInteraction !== undefined) {
42 | eventOptions.non_interaction = nonInteraction;
43 | }
44 |
45 | if (userId !== undefined) {
46 | eventOptions.user_id = userId;
47 | }
48 |
49 | window.gtag("event", action, eventOptions);
50 | }
51 |
--------------------------------------------------------------------------------
/src/interactions/index.ts:
--------------------------------------------------------------------------------
1 | export { pageView } from "./pageView";
2 | export { event } from "./event";
3 | export { consent } from "./consent";
4 |
--------------------------------------------------------------------------------
/src/interactions/pageView.test.ts:
--------------------------------------------------------------------------------
1 | import { pageView } from "./pageView";
2 |
3 | const OLD_ENV = process.env;
4 | const mockGaMeasurementId = "mock";
5 |
6 | beforeEach(() => {
7 | console.warn = jest.fn();
8 | window.gtag = jest.fn();
9 | jest.resetModules();
10 | jest.clearAllMocks();
11 | jest.resetAllMocks();
12 | process.env = { ...OLD_ENV };
13 | });
14 |
15 | afterAll(() => {
16 | process.env = OLD_ENV;
17 | });
18 |
19 | describe("pageView", () => {
20 | const mockTitle = "mock title";
21 | const mockLocation = "mock location";
22 | const mockPath = "mock category";
23 | const mockSendPageView = true;
24 | const mockUserId = "mock user id";
25 |
26 | it("should not call gtag if measurement id is not set", () => {
27 | process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID = undefined;
28 |
29 | pageView();
30 |
31 | expect(window.gtag).not.toBeCalled();
32 | });
33 |
34 | describe("options", () => {
35 | process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID = mockGaMeasurementId;
36 |
37 | it("should call gtag with all the options", () => {
38 | pageView({
39 | title: mockTitle,
40 | location: mockLocation,
41 | path: mockPath,
42 | sendPageView: mockSendPageView,
43 | userId: mockUserId,
44 | });
45 |
46 | expect(window.gtag).toBeCalledTimes(1);
47 | expect(window.gtag).toHaveBeenCalledWith("config", mockGaMeasurementId, {
48 | page_title: mockTitle,
49 | page_location: mockLocation,
50 | page_path: mockPath,
51 | send_page_view: mockSendPageView,
52 | user_id: mockUserId,
53 | });
54 | });
55 |
56 | it("should call gtag without options", () => {
57 | pageView();
58 |
59 | expect(window.gtag).toBeCalledTimes(1);
60 | expect(window.gtag).toHaveBeenCalledWith(
61 | "config",
62 | mockGaMeasurementId,
63 | {}
64 | );
65 | });
66 |
67 | it("should call gtag with page_title when title given", () => {
68 | pageView({
69 | title: mockTitle,
70 | });
71 |
72 | expect(window.gtag).toBeCalledTimes(1);
73 | expect(window.gtag).toHaveBeenCalledWith("config", mockGaMeasurementId, {
74 | page_title: mockTitle,
75 | });
76 | });
77 |
78 | it("should call gtag with page_location when location given", () => {
79 | pageView({
80 | location: mockLocation,
81 | });
82 |
83 | expect(window.gtag).toBeCalledTimes(1);
84 | expect(window.gtag).toHaveBeenCalledWith("config", mockGaMeasurementId, {
85 | page_location: mockLocation,
86 | });
87 | });
88 |
89 | it("should call gtag with page_path when path given", () => {
90 | pageView({
91 | path: mockPath,
92 | });
93 |
94 | expect(window.gtag).toBeCalledTimes(1);
95 | expect(window.gtag).toHaveBeenCalledWith("config", mockGaMeasurementId, {
96 | page_path: mockPath,
97 | });
98 | });
99 |
100 | it("should call gtag with send_page_view when SendPageView", () => {
101 | pageView({
102 | sendPageView: mockSendPageView,
103 | });
104 |
105 | expect(window.gtag).toBeCalledTimes(1);
106 | expect(window.gtag).toHaveBeenCalledWith("config", mockGaMeasurementId, {
107 | send_page_view: mockSendPageView,
108 | });
109 | });
110 |
111 | it("should call gtag with user_id when userId", () => {
112 | pageView({
113 | userId: mockUserId,
114 | });
115 |
116 | expect(window.gtag).toBeCalledTimes(1);
117 | expect(window.gtag).toHaveBeenCalledWith("config", mockGaMeasurementId, {
118 | user_id: mockUserId,
119 | });
120 | });
121 | });
122 | });
123 |
--------------------------------------------------------------------------------
/src/interactions/pageView.ts:
--------------------------------------------------------------------------------
1 | // https://developers.google.com/analytics/devguides/collection/gtagjs/pages
2 |
3 | type PageViewOptions = {
4 | title?: string;
5 | location?: string;
6 | path?: string;
7 | sendPageView?: boolean;
8 | userId?: string;
9 | };
10 |
11 | export function pageView(
12 | { title, location, path, sendPageView, userId }: PageViewOptions = {},
13 | measurementId?: string
14 | ): void {
15 | const gaMeasurementId =
16 | process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID ?? measurementId;
17 |
18 | if (!gaMeasurementId || !window.gtag) {
19 | return;
20 | }
21 |
22 | const pageViewOptions: {
23 | page_title?: string;
24 | page_location?: string;
25 | page_path?: string;
26 | send_page_view?: boolean;
27 | user_id?: string;
28 | } = {};
29 |
30 | if (title !== undefined) {
31 | pageViewOptions.page_title = title;
32 | }
33 |
34 | if (location !== undefined) {
35 | pageViewOptions.page_location = location;
36 | }
37 |
38 | if (path !== undefined) {
39 | pageViewOptions.page_path = path;
40 | }
41 |
42 | if (sendPageView !== undefined) {
43 | pageViewOptions.send_page_view = sendPageView;
44 | }
45 |
46 | if (userId !== undefined) {
47 | pageViewOptions.user_id = userId;
48 | }
49 |
50 | window.gtag("config", gaMeasurementId, pageViewOptions);
51 | }
52 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "commonjs",
5 | "declaration": true,
6 | "sourceMap": true,
7 | "outDir": "./dist",
8 | "removeComments": true,
9 | "importHelpers": true,
10 | "strict": true,
11 | "esModuleInterop": true,
12 | "skipLibCheck": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "jsx": "react"
15 | },
16 | "include": ["./src/**/*.ts"],
17 | "exclude": ["./src/**/*.test.ts"]
18 | }
19 |
--------------------------------------------------------------------------------