diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index e684bac..e68d62d 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -88,7 +88,7 @@ export default async function RootLayout({
}
return (
-
+
diff --git a/apps/web/src/components/PlausibleProvider.tsx b/apps/web/src/components/PlausibleProvider.tsx
index 5f7964c..83dee19 100644
--- a/apps/web/src/components/PlausibleProvider.tsx
+++ b/apps/web/src/components/PlausibleProvider.tsx
@@ -1,7 +1,8 @@
"use client";
-import { Suspense, useEffect, useRef } from "react";
+import { Suspense, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
+import { initializePlausible, trackPlausible } from "@/lib/plausible";
/**
* Inner component that uses useSearchParams (requires Suspense boundary).
@@ -9,10 +10,7 @@ import { usePathname, useSearchParams } from "next/navigation";
function PlausibleTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
- const isInitialized = useRef(false);
- const initialization = useRef | null>(null);
- // Initialize Plausible once
useEffect(() => {
const domain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN;
@@ -23,46 +21,32 @@ function PlausibleTracker() {
return;
}
- initialization.current = import("@plausible-analytics/tracker").then(({ init }) => {
- if (!isInitialized.current) {
- init({
- domain,
- captureOnLocalhost: process.env.NEXT_PUBLIC_PLAUSIBLE_CAPTURE_LOCALHOST === "true",
- outboundLinks: true,
- fileDownloads: true,
- formSubmissions: true,
- autoCapturePageviews: false,
- });
- isInitialized.current = true;
- }
+ initializePlausible({
+ domain,
+ captureOnLocalhost:
+ process.env.NEXT_PUBLIC_PLAUSIBLE_CAPTURE_LOCALHOST === "true",
+ outboundLinks: true,
+ fileDownloads: true,
+ formSubmissions: true,
+ autoCapturePageviews: false,
});
}, []);
- // Track page views on route changes
useEffect(() => {
- if (!process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || !initialization.current) {
+ if (!process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN) {
return;
}
- // Build the full URL for tracking
const url = searchParams.toString()
? `${pathname}?${searchParams.toString()}`
: pathname;
- // Track pageview with the current URL
- void initialization.current.then(async () => {
- const { track } = await import("@plausible-analytics/tracker");
- track("pageview", { url });
- });
+ void trackPlausible("pageview", { url });
}, [pathname, searchParams]);
return null;
}
-/**
- * Initializes Plausible Analytics and tracks page views on route changes.
- * Must be rendered within the app to enable tracking.
- */
export function PlausibleProvider() {
return (
diff --git a/apps/web/src/lib/__tests__/analytics.test.ts b/apps/web/src/lib/__tests__/analytics.test.ts
index 6b3ee8c..a03f5c0 100644
--- a/apps/web/src/lib/__tests__/analytics.test.ts
+++ b/apps/web/src/lib/__tests__/analytics.test.ts
@@ -1,8 +1,43 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const init = vi.fn();
+const track = vi.fn();
+
+vi.mock("@plausible-analytics/tracker", () => ({
+ init,
+ track,
+}));
+
+afterEach(() => {
+ vi.clearAllMocks();
+ Reflect.deleteProperty(globalThis, "window");
+});
describe("analytics modules", () => {
it("can load during server rendering without browser globals", async () => {
await expect(import("../analytics")).resolves.toBeDefined();
await expect(import("../../components/PlausibleProvider")).resolves.toBeDefined();
});
+
+ it("initializes Plausible once across repeated callers", async () => {
+ globalThis.window = {} as Window & typeof globalThis;
+ const { initializePlausible } = await import("../plausible");
+ const config = { domain: "example.com" };
+
+ const first = initializePlausible(config);
+ const second = initializePlausible(config);
+
+ expect(first).toBe(second);
+ await first;
+ expect(init).toHaveBeenCalledOnce();
+ });
+
+ it("does not track custom events before initialization", async () => {
+ globalThis.window = {} as Window & typeof globalThis;
+ const { trackPlausible } = await import("../plausible");
+
+ await trackPlausible("signup", {});
+
+ expect(track).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts
index e112213..ec8b43b 100644
--- a/apps/web/src/lib/analytics.ts
+++ b/apps/web/src/lib/analytics.ts
@@ -22,12 +22,10 @@ export function trackEvent(
return;
}
- void import("@plausible-analytics/tracker").then(({ track }) => {
- track(eventName, {
- props,
- interactive: options?.interactive,
- revenue: options?.revenue,
- });
+ void trackPlausible(eventName, {
+ props,
+ interactive: options?.interactive,
+ revenue: options?.revenue,
});
}
@@ -64,3 +62,4 @@ export const AnalyticsEvents = {
} as const;
export type AnalyticsEvent = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
+import { trackPlausible } from "./plausible";
diff --git a/apps/web/src/lib/plausible.ts b/apps/web/src/lib/plausible.ts
new file mode 100644
index 0000000..6eb8634
--- /dev/null
+++ b/apps/web/src/lib/plausible.ts
@@ -0,0 +1,40 @@
+import type { PlausibleConfig } from "@plausible-analytics/tracker";
+
+declare global {
+ interface Window {
+ __vcpPlausibleInitialization?: Promise;
+ }
+}
+
+export function initializePlausible(
+ config: PlausibleConfig
+): Promise | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ window.__vcpPlausibleInitialization ??= import(
+ "@plausible-analytics/tracker"
+ ).then(({ init }) => {
+ init(config);
+ });
+
+ return window.__vcpPlausibleInitialization;
+}
+
+export async function trackPlausible(
+ eventName: string,
+ options: Parameters<
+ typeof import("@plausible-analytics/tracker").track
+ >[1]
+): Promise {
+ const initialization = window.__vcpPlausibleInitialization;
+
+ if (!initialization) {
+ return;
+ }
+
+ await initialization;
+ const { track } = await import("@plausible-analytics/tracker");
+ track(eventName, options);
+}