diff --git a/apps/nextjs-15/src/app/blog/[slug]/page.tsx b/apps/nextjs-15/src/app/blog/[slug]/page.tsx
index 4cd80c9..430ab22 100644
--- a/apps/nextjs-15/src/app/blog/[slug]/page.tsx
+++ b/apps/nextjs-15/src/app/blog/[slug]/page.tsx
@@ -1,4 +1,5 @@
import Link from 'next/link';
+import styles from '../layout.module.css';
export default async function BlogPage({
params,
@@ -7,10 +8,15 @@ export default async function BlogPage({
}) {
const { slug } = await params;
return (
-
-
My first blog post
-
Feature just got released
-
-
{children}
+
+
+
+ Home
+
+ {children}
+
);
}
diff --git a/apps/nextjs-15/src/app/blog/page.tsx b/apps/nextjs-15/src/app/blog/page.tsx
index 1278d9f..bcb07fa 100644
--- a/apps/nextjs-15/src/app/blog/page.tsx
+++ b/apps/nextjs-15/src/app/blog/page.tsx
@@ -1,3 +1,19 @@
+import Link from 'next/link';
+import styles from './layout.module.css';
+
export default function Blog() {
- return
Welcome to the blog
;
+ return (
+ <>
+
Blog
+
Posts
+
+ >
+ );
}
diff --git a/apps/nextjs-15/src/app/experiment/exposure-tracker.tsx b/apps/nextjs-15/src/app/experiment/exposure-tracker.tsx
new file mode 100644
index 0000000..55e3bdd
--- /dev/null
+++ b/apps/nextjs-15/src/app/experiment/exposure-tracker.tsx
@@ -0,0 +1,24 @@
+'use client';
+import { track } from '@vercel/analytics/next';
+import { useEffect } from 'react';
+import styles from './page.module.css';
+
+let tracked = false;
+
+export function ExposureTracker() {
+ useEffect(() => {
+ if (tracked) return;
+ tracked = true;
+ track('exposure');
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/apps/nextjs-15/src/app/experiment/page.module.css b/apps/nextjs-15/src/app/experiment/page.module.css
new file mode 100644
index 0000000..c999e3d
--- /dev/null
+++ b/apps/nextjs-15/src/app/experiment/page.module.css
@@ -0,0 +1,113 @@
+.page {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100svh;
+ padding: 80px;
+ font-family: var(--font-geist-sans);
+}
+
+.main {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ max-width: 480px;
+ width: 100%;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.badge::before {
+ content: "";
+ display: block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #22c55e;
+}
+
+.main h1 {
+ font-size: 36px;
+ font-weight: 600;
+ letter-spacing: -0.03em;
+ line-height: 1.15;
+ margin: 0;
+}
+
+.description {
+ font-size: 15px;
+ line-height: 1.6;
+ color: rgba(0, 0, 0, 0.5);
+ margin: 0;
+}
+
+.button {
+ align-self: flex-start;
+ appearance: none;
+ background: var(--foreground);
+ color: var(--background);
+ border: none;
+ border-radius: 10px;
+ padding: 12px 20px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .button:hover {
+ opacity: 0.8;
+ }
+}
+
+.back {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: rgba(0, 0, 0, 0.4);
+ text-decoration: none;
+ transition: color 0.15s;
+}
+
+.back::before {
+ content: "←";
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .back:hover {
+ color: var(--foreground);
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ .badge {
+ color: rgba(255, 255, 255, 0.4);
+ }
+
+ .description {
+ color: rgba(255, 255, 255, 0.5);
+ }
+
+ .back {
+ color: rgba(255, 255, 255, 0.4);
+ }
+}
+
+@media (max-width: 600px) {
+ .page {
+ padding: 32px;
+ align-items: flex-start;
+ padding-top: 64px;
+ }
+}
diff --git a/apps/nextjs-15/src/app/experiment/page.tsx b/apps/nextjs-15/src/app/experiment/page.tsx
new file mode 100644
index 0000000..7a86f1e
--- /dev/null
+++ b/apps/nextjs-15/src/app/experiment/page.tsx
@@ -0,0 +1,23 @@
+import Link from 'next/link';
+import { ExposureTracker } from './exposure-tracker';
+import styles from './page.module.css';
+
+export default function ExperimentPage() {
+ return (
+
+
+
+ Home
+
+ Experiment
+ See what we're testing
+
+ This page is part of an active experiment. Your visit has been
+ recorded as an exposure, and you can interact below to track
+ additional events.
+
+
+
+
+ );
+}
diff --git a/apps/nextjs-15/src/app/page.module.css b/apps/nextjs-15/src/app/page.module.css
index ee9b8e6..3b52366 100644
--- a/apps/nextjs-15/src/app/page.module.css
+++ b/apps/nextjs-15/src/app/page.module.css
@@ -1,168 +1,78 @@
.page {
- --gray-rgb: 0, 0, 0;
- --gray-alpha-200: rgba(var(--gray-rgb), 0.08);
- --gray-alpha-100: rgba(var(--gray-rgb), 0.05);
-
- --button-primary-hover: #383838;
- --button-secondary-hover: #f2f2f2;
-
- display: grid;
- grid-template-rows: 20px 1fr 20px;
+ display: flex;
align-items: center;
- justify-items: center;
+ justify-content: center;
min-height: 100svh;
padding: 80px;
- gap: 64px;
font-family: var(--font-geist-sans);
}
-@media (prefers-color-scheme: dark) {
- .page {
- --gray-rgb: 255, 255, 255;
- --gray-alpha-200: rgba(var(--gray-rgb), 0.145);
- --gray-alpha-100: rgba(var(--gray-rgb), 0.06);
-
- --button-primary-hover: #ccc;
- --button-secondary-hover: #1a1a1a;
- }
-}
-
.main {
display: flex;
flex-direction: column;
- gap: 32px;
- grid-row-start: 2;
+ gap: 40px;
+ max-width: 480px;
+ width: 100%;
}
-.main ol {
- font-family: var(--font-geist-mono);
- padding-left: 0;
- margin: 0;
- font-size: 14px;
- line-height: 24px;
- letter-spacing: -0.01em;
- list-style-position: inside;
-}
-
-.main li:not(:last-of-type) {
- margin-bottom: 8px;
-}
-
-.main code {
- font-family: inherit;
- background: var(--gray-alpha-100);
- padding: 2px 4px;
- border-radius: 4px;
+.main h1 {
+ font-size: 32px;
font-weight: 600;
+ letter-spacing: -0.03em;
+ line-height: 1.2;
}
-.ctas {
+.nav {
display: flex;
- gap: 16px;
+ flex-direction: column;
+ gap: 12px;
}
-.ctas a {
- appearance: none;
- border-radius: 128px;
- height: 48px;
- padding: 0 20px;
- border: none;
- border: 1px solid transparent;
- transition:
- background 0.2s,
- color 0.2s,
- border-color 0.2s;
- cursor: pointer;
+.link {
display: flex;
align-items: center;
- justify-content: center;
- font-size: 16px;
- line-height: 20px;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-radius: 12px;
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ font-size: 15px;
font-weight: 500;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
}
-a.primary {
- background: var(--foreground);
- color: var(--background);
- gap: 8px;
-}
-
-a.secondary {
- border-color: var(--gray-alpha-200);
- min-width: 180px;
-}
-
-.footer {
- grid-row-start: 3;
- display: flex;
- gap: 24px;
-}
-
-.footer a {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.footer img {
- flex-shrink: 0;
+.link::after {
+ content: "→";
+ opacity: 0.4;
}
-/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
- a.primary:hover {
- background: var(--button-primary-hover);
- border-color: transparent;
- }
-
- a.secondary:hover {
- background: var(--button-secondary-hover);
- border-color: transparent;
+ .link:hover {
+ background: rgba(0, 0, 0, 0.04);
+ border-color: rgba(0, 0, 0, 0.15);
}
- .footer a:hover {
- text-decoration: underline;
- text-underline-offset: 4px;
+ .link:hover::after {
+ opacity: 1;
}
}
-@media (max-width: 600px) {
- .page {
- padding: 32px;
- padding-bottom: 80px;
- }
-
- .main {
- align-items: center;
- }
-
- .main ol {
- text-align: center;
- }
-
- .ctas {
- flex-direction: column;
- }
-
- .ctas a {
- font-size: 14px;
- height: 40px;
- padding: 0 16px;
- }
-
- a.secondary {
- min-width: auto;
+@media (prefers-color-scheme: dark) {
+ .link {
+ border-color: rgba(255, 255, 255, 0.1);
}
- .footer {
- flex-wrap: wrap;
- align-items: center;
- justify-content: center;
+ .link:hover {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.2);
}
}
-@media (prefers-color-scheme: dark) {
- .logo {
- filter: invert();
+@media (max-width: 600px) {
+ .page {
+ padding: 32px;
+ align-items: flex-start;
+ padding-top: 64px;
}
}
diff --git a/apps/nextjs-15/src/app/page.tsx b/apps/nextjs-15/src/app/page.tsx
index 8c170c1..b611270 100644
--- a/apps/nextjs-15/src/app/page.tsx
+++ b/apps/nextjs-15/src/app/page.tsx
@@ -1,95 +1,20 @@
-import Image from 'next/image';
+import Link from 'next/link';
import styles from './page.module.css';
export default function Home() {
return (
-
-
- -
- Get started by editing
src/app/page.tsx.
-
- - Save and see your changes instantly.
-
-
-
+ Next.js 15 Analytics Demo
+
-
);
}
diff --git a/apps/nextjs/e2e/development/beforeSend.spec.ts b/apps/nextjs/e2e/development/beforeSend.spec.ts
index 5d59d45..f161720 100644
--- a/apps/nextjs/e2e/development/beforeSend.spec.ts
+++ b/apps/nextjs/e2e/development/beforeSend.spec.ts
@@ -35,13 +35,13 @@ test.describe('beforeSend', () => {
expect(
messages.find((m) =>
- m.includes('[pageview] http://localhost:3000/before-send/first'),
+ m.includes('[view] http://localhost:3000/before-send/first'),
),
).toBeDefined();
expect(
messages.find((m) =>
m.includes(
- '[pageview] http://localhost:3000/before-send/second?secret=REDACTED',
+ '[view] http://localhost:3000/before-send/second?secret=REDACTED',
),
),
).toBeDefined();
diff --git a/apps/nextjs/e2e/development/pageview.spec.ts b/apps/nextjs/e2e/development/pageview.spec.ts
index 5ac69d8..0368e32 100644
--- a/apps/nextjs/e2e/development/pageview.spec.ts
+++ b/apps/nextjs/e2e/development/pageview.spec.ts
@@ -35,12 +35,12 @@ test.describe('pageview', () => {
expect(
messages.find((m) =>
- m.includes('[pageview] http://localhost:3000/navigation/first'),
+ m.includes('[view] http://localhost:3000/navigation/first'),
),
).toBeDefined();
expect(
messages.find((m) =>
- m.includes('[pageview] http://localhost:3000/navigation/second'),
+ m.includes('[view] http://localhost:3000/navigation/second'),
),
).toBeDefined();
});
diff --git a/apps/nextjs/e2e/utils.ts b/apps/nextjs/e2e/utils.ts
index 36044ed..b5c337d 100644
--- a/apps/nextjs/e2e/utils.ts
+++ b/apps/nextjs/e2e/utils.ts
@@ -10,15 +10,13 @@ export async function useMockForProductionScript(props: {
"Object.defineProperty(navigator, 'webdriver', { get() { return undefined }})",
});
- await props.page.route('**/_vercel/insights/script.js', async (route, _) => {
- return route.fulfill({
- status: 301,
- headers: {
- location: props.debug
- ? 'https://cdn.vercel-insights.com/v1/script.debug.js'
- : 'https://cdn.vercel-insights.com/v1/script.js',
- },
+ await props.page.route('**/_vercel/insights/script.js', async (route) => {
+ const response = await route.fetch({
+ url: props.debug
+ ? 'https://va.vercel-scripts.com/v1/script.debug.js'
+ : 'https://va.vercel-scripts.com/v1/script.js',
});
+ return route.fulfill({ response });
});
await props.page.route('**/_vercel/insights/view', async (route, request) => {
diff --git a/packages/web/src/generic.test.ts b/packages/web/src/generic.test.ts
index 5cfd915..072c60d 100644
--- a/packages/web/src/generic.test.ts
+++ b/packages/web/src/generic.test.ts
@@ -138,6 +138,22 @@ describe.each([
});
});
+ describe('track before inject', () => {
+ beforeEach(() => {
+ // simulate a fresh page load where inject hasn't run yet
+ window.va = undefined;
+ window.vaq = undefined;
+ });
+
+ it('initializes the queue and buffers events with properties', () => {
+ track('exposure', { variant: 'A' });
+ expect(window.vaq?.[0]).toEqual([
+ 'event',
+ { name: 'exposure', data: { variant: 'A' } },
+ ]);
+ });
+ });
+
describe('track custom events', () => {
beforeEach(() => {
// reset the internal queue before every test
diff --git a/packages/web/src/generic.ts b/packages/web/src/generic.ts
index d2bce5e..6df4f27 100644
--- a/packages/web/src/generic.ts
+++ b/packages/web/src/generic.ts
@@ -89,6 +89,10 @@ function track(
return;
}
+ // in case the track function is invoked even before inject
+ // (can happen because react renders children before their parents)
+ // make sure we do not miss them.
+ initQueue();
if (!properties) {
window.va?.('event', { name, options });
diff --git a/packages/web/src/nextjs/index.tsx b/packages/web/src/nextjs/index.tsx
index f9c1480..2ebd284 100644
--- a/packages/web/src/nextjs/index.tsx
+++ b/packages/web/src/nextjs/index.tsx
@@ -1,5 +1,6 @@
'use client';
import React, { type ReactNode, Suspense } from 'react';
+import { track } from '../generic';
import { Analytics as AnalyticsScript } from '../react';
import type { AnalyticsProps, BeforeSend, BeforeSendEvent } from '../types';
import { getBasePath, getConfigString, useRoute } from './utils';
@@ -29,4 +30,5 @@ export function Analytics(props: Props): null {
) as never;
}
+export { track };
export type { AnalyticsProps, BeforeSend, BeforeSendEvent };
diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts
index 8f0d7a2..1569798 100644
--- a/packages/web/src/types.ts
+++ b/packages/web/src/types.ts
@@ -1,22 +1,35 @@
+export type Mode = 'auto' | 'development' | 'production';
+export type AllowedPropertyValues =
+ | string
+ | number
+ | boolean
+ | null
+ | undefined;
+
+export type PlainFlags = Record
;
+export type FlagsDataInput = (string | PlainFlags)[] | PlainFlags;
+
+export type TrackEventPayload = {
+ name: string;
+ data?: Record;
+ options?: {
+ flags?: FlagsDataInput;
+ };
+};
+
interface PageViewEvent {
type: 'pageview';
url: string;
}
+
interface CustomEvent {
type: 'event';
url: string;
+ payload: TrackEventPayload;
}
export type BeforeSendEvent = PageViewEvent | CustomEvent;
-export type Mode = 'auto' | 'development' | 'production';
-export type AllowedPropertyValues =
- | string
- | number
- | boolean
- | null
- | undefined;
-
export type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null;
export interface AnalyticsProps {
@@ -53,6 +66,3 @@ declare global {
webAnalyticsBeforeSend?: BeforeSend;
}
}
-
-export type PlainFlags = Record;
-export type FlagsDataInput = (string | PlainFlags)[] | PlainFlags;