diff --git a/packages/next/package.json b/packages/next/package.json
index ef03cab78be5..de650d6b5fb9 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -57,7 +57,9 @@
"navigation.d.ts",
"headers.js",
"headers.d.ts",
- "navigation-types"
+ "navigation-types",
+ "web-vitals.js",
+ "web-vitals.d.ts"
],
"bin": {
"next": "./dist/bin/next"
diff --git a/packages/next/src/client/web-vitals.ts b/packages/next/src/client/web-vitals.ts
new file mode 100644
index 000000000000..4194cf90f566
--- /dev/null
+++ b/packages/next/src/client/web-vitals.ts
@@ -0,0 +1,23 @@
+import { useEffect } from 'react'
+import {
+ onLCP,
+ onFID,
+ onCLS,
+ onINP,
+ onFCP,
+ onTTFB,
+ Metric,
+} from 'next/dist/compiled/web-vitals'
+
+export function useReportWebVitals(
+ reportWebVitalsFn: (metric: Metric) => void
+) {
+ useEffect(() => {
+ onCLS(reportWebVitalsFn)
+ onFID(reportWebVitalsFn)
+ onLCP(reportWebVitalsFn)
+ onINP(reportWebVitalsFn)
+ onFCP(reportWebVitalsFn)
+ onTTFB(reportWebVitalsFn)
+ }, [reportWebVitalsFn])
+}
diff --git a/packages/next/web-vitals.d.ts b/packages/next/web-vitals.d.ts
new file mode 100644
index 000000000000..83f283d4a17e
--- /dev/null
+++ b/packages/next/web-vitals.d.ts
@@ -0,0 +1 @@
+export * from './dist/client/web-vitals'
diff --git a/packages/next/web-vitals.js b/packages/next/web-vitals.js
new file mode 100644
index 000000000000..f6123622a020
--- /dev/null
+++ b/packages/next/web-vitals.js
@@ -0,0 +1 @@
+module.exports = require('./dist/client/web-vitals')
diff --git a/test/e2e/app-dir/app/app/report-web-vitals/layout.js b/test/e2e/app-dir/app/app/report-web-vitals/layout.js
new file mode 100644
index 000000000000..3b12807859d0
--- /dev/null
+++ b/test/e2e/app-dir/app/app/report-web-vitals/layout.js
@@ -0,0 +1,19 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+
+export default function ClientNestedLayout({ children }) {
+ const [count, setCount] = useState(0)
+ useEffect(() => {
+ setCount(1)
+ }, [])
+ return (
+ <>
+
Client Nested. Count: {count}
+
+ {children}
+ >
+ )
+}
diff --git a/test/e2e/app-dir/app/app/report-web-vitals/page.js b/test/e2e/app-dir/app/app/report-web-vitals/page.js
new file mode 100644
index 000000000000..fc54c58899e2
--- /dev/null
+++ b/test/e2e/app-dir/app/app/report-web-vitals/page.js
@@ -0,0 +1,10 @@
+import Reporter from './reporter'
+
+export default function component() {
+ return (
+ <>
+ Test
+
+ >
+ )
+}
diff --git a/test/e2e/app-dir/app/app/report-web-vitals/reporter.js b/test/e2e/app-dir/app/app/report-web-vitals/reporter.js
new file mode 100644
index 000000000000..c953e96a58e0
--- /dev/null
+++ b/test/e2e/app-dir/app/app/report-web-vitals/reporter.js
@@ -0,0 +1,17 @@
+'use client'
+import { useReportWebVitals } from 'next/web-vitals'
+
+const report = (metric) => {
+ const blob = new Blob([new URLSearchParams(metric).toString()])
+ const vitalsUrl = 'https://example.vercel.sh/vitals'
+ fetch(vitalsUrl, {
+ body: blob,
+ method: 'POST',
+ credentials: 'omit',
+ keepalive: true,
+ })
+}
+
+export default function Reporter() {
+ useReportWebVitals(report)
+}
diff --git a/test/e2e/app-dir/app/useReportWebVitals.test.ts b/test/e2e/app-dir/app/useReportWebVitals.test.ts
new file mode 100644
index 000000000000..59b98b607428
--- /dev/null
+++ b/test/e2e/app-dir/app/useReportWebVitals.test.ts
@@ -0,0 +1,53 @@
+import { createNext } from 'e2e-utils'
+import { NextInstance } from 'test/lib/next-modes/base'
+import { check } from 'next-test-utils'
+
+describe('useReportWebVitals hook', () => {
+ let next: NextInstance
+
+ beforeAll(async () => {
+ next = await createNext({
+ files: __dirname,
+ dependencies: {
+ swr: '2.0.0-rc.0',
+ react: 'latest',
+ 'react-dom': 'latest',
+ sass: 'latest',
+ },
+ skipStart: true,
+ env: {},
+ })
+
+ await next.start()
+ })
+ afterAll(() => next.destroy())
+
+ // Analytics events are only sent in production
+ it('should send web-vitals to vercel-insights', async () => {
+ let countEvents = false
+ let eventsCount = 0
+ const browser = await next.browser('/report-web-vitals', {
+ beforePageLoad: (page) => {
+ page.route('https://example.vercel.sh/vitals', (route) => {
+ if (countEvents) {
+ eventsCount += 1
+ }
+
+ route.fulfill()
+ })
+ },
+ })
+
+ // Start counting analytics events
+ countEvents = true
+
+ // Refresh will trigger CLS and LCP. When page loads FCP and TTFB will trigger:
+ await browser.refresh()
+
+ // After interaction LCP and FID will trigger
+ await browser.elementById('btn').click()
+
+ // Make sure all registered events in performance-relayer has fired
+ await check(() => eventsCount, /6/)
+ })
+})