From 560fe89140111431ea464125ea3774ec1bf924ba Mon Sep 17 00:00:00 2001
From: Mike Hartington
Date: Tue, 7 Apr 2026 09:46:12 -0400
Subject: [PATCH 1/8] feat(site): utm persistence
---
apps/site/src/app/layout.tsx | 2 +
.../src/components/navigation-wrapper.tsx | 40 +++++++-
apps/site/src/components/utm-persistence.tsx | 94 +++++++++++++++++++
apps/site/src/lib/utm.ts | 52 ++++++++++
packages/ui/src/components/web-navigation.tsx | 41 ++++++--
5 files changed, 219 insertions(+), 10 deletions(-)
create mode 100644 apps/site/src/components/utm-persistence.tsx
create mode 100644 apps/site/src/lib/utm.ts
diff --git a/apps/site/src/app/layout.tsx b/apps/site/src/app/layout.tsx
index d7be4dd3cd..6ed6b3bbfb 100644
--- a/apps/site/src/app/layout.tsx
+++ b/apps/site/src/app/layout.tsx
@@ -15,6 +15,7 @@ import {
import { Footer } from "@prisma-docs/ui/components/footer";
import { ThemeProvider } from "@prisma-docs/ui/components/theme-provider";
import { FontAwesomeScript as WebFA } from "@prisma/eclipse";
+import { UtmPersistence } from "@/components/utm-persistence";
const inter = Inter({
subsets: ["latin"],
@@ -187,6 +188,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
+
({
+ utm_source: utm.source,
+ });
+
+ useEffect(() => {
+ const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString()));
+
+ if (currentUtmParams.utm_source) {
+ setStoredUtmParams(currentUtmParams);
+ writeStoredUtmParams(currentUtmParams);
+ return;
+ }
+
+ const persistedUtmParams = readStoredUtmParams();
+ setStoredUtmParams(
+ persistedUtmParams.utm_source
+ ? persistedUtmParams
+ : { utm_source: utm.source },
+ );
+ }, [searchParams, utm.source]);
// Determine button variant based on pathname
const getButtonVariant = (): ColorType => {
@@ -62,7 +90,13 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
return (
);
diff --git a/apps/site/src/components/utm-persistence.tsx b/apps/site/src/components/utm-persistence.tsx
new file mode 100644
index 0000000000..653b13257a
--- /dev/null
+++ b/apps/site/src/components/utm-persistence.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { useEffect } from "react";
+import { usePathname, useSearchParams } from "next/navigation";
+import {
+ getUtmParams,
+ hasUtmParams,
+ mergeUtmParams,
+ readStoredUtmParams,
+ writeStoredUtmParams,
+} from "@/lib/utm";
+
+export function UtmPersistence() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString()));
+
+ if (hasUtmParams(currentUtmParams)) {
+ writeStoredUtmParams(currentUtmParams);
+ return;
+ }
+
+ const storedUtmParams = readStoredUtmParams();
+
+ if (!hasUtmParams(storedUtmParams)) {
+ return;
+ }
+
+ const currentUrl = new URL(window.location.href);
+
+ if (!mergeUtmParams(currentUrl, storedUtmParams)) {
+ return;
+ }
+
+ window.history.replaceState(
+ window.history.state,
+ "",
+ `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`,
+ );
+ }, [pathname, searchParams]);
+
+ useEffect(() => {
+ function handleClick(event: MouseEvent) {
+ const anchor = (event.target as HTMLElement).closest(
+ "a[href]",
+ );
+
+ if (!anchor) {
+ return;
+ }
+
+ const href = anchor.getAttribute("href");
+
+ if (
+ !href ||
+ href.startsWith("#") ||
+ href.startsWith("mailto:") ||
+ href.startsWith("tel:") ||
+ anchor.target === "_blank" ||
+ anchor.hasAttribute("download")
+ ) {
+ return;
+ }
+
+ const storedUtmParams = readStoredUtmParams();
+
+ if (!hasUtmParams(storedUtmParams)) {
+ return;
+ }
+
+ const targetUrl = new URL(anchor.href, window.location.href);
+
+ if (targetUrl.origin !== window.location.origin) {
+ return;
+ }
+
+ if (!mergeUtmParams(targetUrl, storedUtmParams)) {
+ return;
+ }
+
+ anchor.setAttribute(
+ "href",
+ `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`,
+ );
+ }
+
+ document.addEventListener("click", handleClick, true);
+ return () => document.removeEventListener("click", handleClick, true);
+ }, []);
+
+ return null;
+}
diff --git a/apps/site/src/lib/utm.ts b/apps/site/src/lib/utm.ts
new file mode 100644
index 0000000000..0324f24e35
--- /dev/null
+++ b/apps/site/src/lib/utm.ts
@@ -0,0 +1,52 @@
+export const UTM_STORAGE_KEY = "site_utm_params";
+
+export type UtmParams = Record;
+
+export function getUtmParams(searchParams: URLSearchParams): UtmParams {
+ const utmParams: UtmParams = {};
+
+ for (const [key, value] of searchParams.entries()) {
+ if (key.startsWith("utm_") && value) {
+ utmParams[key] = value;
+ }
+ }
+
+ return utmParams;
+}
+
+export function hasUtmParams(utmParams: UtmParams) {
+ return Object.keys(utmParams).length > 0;
+}
+
+export function mergeUtmParams(url: URL, utmParams: UtmParams) {
+ let updated = false;
+
+ for (const [key, value] of Object.entries(utmParams)) {
+ if (!url.searchParams.has(key)) {
+ url.searchParams.set(key, value);
+ updated = true;
+ }
+ }
+
+ return updated;
+}
+
+export function readStoredUtmParams() {
+ const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY);
+
+ if (!storedUtmParams) {
+ return {};
+ }
+
+ try {
+ return JSON.parse(storedUtmParams) as UtmParams;
+ } catch {
+ return {};
+ }
+}
+
+export function writeStoredUtmParams(utmParams: UtmParams) {
+ if (hasUtmParams(utmParams)) {
+ window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(utmParams));
+ }
+}
diff --git a/packages/ui/src/components/web-navigation.tsx b/packages/ui/src/components/web-navigation.tsx
index dfe9c29138..00a9b2d613 100644
--- a/packages/ui/src/components/web-navigation.tsx
+++ b/packages/ui/src/components/web-navigation.tsx
@@ -38,24 +38,51 @@ export interface Link {
interface WebNavigationProps {
links: Link[];
utm?: {
- source: "website";
+ source: string;
medium: string;
+ campaign?: string;
+ content?: string;
+ term?: string;
};
buttonVariant?: "ppg" | "orm" | undefined;
}
+function buildConsoleHref(
+ pathname: "/login" | "/sign-up",
+ utm?: WebNavigationProps["utm"],
+) {
+ if (!utm) {
+ return `https://console.prisma.io${pathname}`;
+ }
+
+ const href = new URL(`https://console.prisma.io${pathname}`);
+
+ href.searchParams.set("utm_source", utm.source);
+ href.searchParams.set("utm_medium", utm.medium);
+ href.searchParams.set(
+ "utm_campaign",
+ utm.campaign || (pathname === "/login" ? "login" : "signup"),
+ );
+
+ if (utm.content) {
+ href.searchParams.set("utm_content", utm.content);
+ }
+
+ if (utm.term) {
+ href.searchParams.set("utm_term", utm.term);
+ }
+
+ return href.toString();
+}
+
export function WebNavigation({
links,
utm,
buttonVariant = "ppg",
}: WebNavigationProps) {
const [mobileView, setMobileView] = useState(false);
- const loginHref = utm
- ? `https://console.prisma.io/login?utm_source=${utm.source}&utm_medium=${utm.medium}&utm_campaign=login`
- : "https://console.prisma.io/login";
- const signupHref = utm
- ? `https://console.prisma.io/sign-up?utm_source=${utm.source}&utm_medium=${utm.medium}&utm_campaign=signup`
- : "https://console.prisma.io/sign-up";
+ const loginHref = buildConsoleHref("/login", utm);
+ const signupHref = buildConsoleHref("/sign-up", utm);
useEffect(() => {
if (mobileView) {
From fdad040f510296828c6bd543ceffbf06ef8742f9 Mon Sep 17 00:00:00 2001
From: Mike Hartington
Date: Tue, 7 Apr 2026 10:04:31 -0400
Subject: [PATCH 2/8] fix(site): failing build
---
apps/site/src/components/navigation-wrapper.tsx | 9 +++++----
apps/site/src/components/utm-persistence.tsx | 9 +++++----
2 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx
index 942aa8cd7a..ddbf062647 100644
--- a/apps/site/src/components/navigation-wrapper.tsx
+++ b/apps/site/src/components/navigation-wrapper.tsx
@@ -3,7 +3,7 @@
import { WebNavigation } from "@prisma-docs/ui/components/web-navigation";
import { Footer } from "@prisma-docs/ui/components/footer";
import { useEffect, useState } from "react";
-import { usePathname, useSearchParams } from "next/navigation";
+import { usePathname } from "next/navigation";
import {
getUtmParams,
readStoredUtmParams,
@@ -56,13 +56,14 @@ function getUtmMedium(pathname: string) {
export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
const pathname = usePathname();
- const searchParams = useSearchParams();
const [storedUtmParams, setStoredUtmParams] = useState({
utm_source: utm.source,
});
useEffect(() => {
- const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString()));
+ const currentUtmParams = getUtmParams(
+ new URLSearchParams(window.location.search),
+ );
if (currentUtmParams.utm_source) {
setStoredUtmParams(currentUtmParams);
@@ -76,7 +77,7 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
? persistedUtmParams
: { utm_source: utm.source },
);
- }, [searchParams, utm.source]);
+ }, [pathname, utm.source]);
// Determine button variant based on pathname
const getButtonVariant = (): ColorType => {
diff --git a/apps/site/src/components/utm-persistence.tsx b/apps/site/src/components/utm-persistence.tsx
index 653b13257a..79035116c9 100644
--- a/apps/site/src/components/utm-persistence.tsx
+++ b/apps/site/src/components/utm-persistence.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect } from "react";
-import { usePathname, useSearchParams } from "next/navigation";
+import { usePathname } from "next/navigation";
import {
getUtmParams,
hasUtmParams,
@@ -12,10 +12,11 @@ import {
export function UtmPersistence() {
const pathname = usePathname();
- const searchParams = useSearchParams();
useEffect(() => {
- const currentUtmParams = getUtmParams(new URLSearchParams(searchParams.toString()));
+ const currentUtmParams = getUtmParams(
+ new URLSearchParams(window.location.search),
+ );
if (hasUtmParams(currentUtmParams)) {
writeStoredUtmParams(currentUtmParams);
@@ -39,7 +40,7 @@ export function UtmPersistence() {
"",
`${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`,
);
- }, [pathname, searchParams]);
+ }, [pathname]);
useEffect(() => {
function handleClick(event: MouseEvent) {
From b9c01d49fb3ba0207a6b5e5998cc0be5a394b0c1 Mon Sep 17 00:00:00 2001
From: Mike Hartington
Date: Tue, 7 Apr 2026 10:10:41 -0400
Subject: [PATCH 3/8] fix(site): harden persisted utm propagation
Preserve arbitrary persisted UTM fields across site and console navigation while making storage access safer and available on first render.
Made-with: Cursor
---
apps/blog/src/app/(blog)/layout.tsx | 2 +-
.../src/components/navigation-wrapper.tsx | 32 +++++++--------
apps/site/src/lib/utm.ts | 41 ++++++++++++++++---
packages/ui/src/components/web-navigation.tsx | 28 +++++--------
4 files changed, 61 insertions(+), 42 deletions(-)
diff --git a/apps/blog/src/app/(blog)/layout.tsx b/apps/blog/src/app/(blog)/layout.tsx
index 54ce2154d2..c43c9e6aa0 100644
--- a/apps/blog/src/app/(blog)/layout.tsx
+++ b/apps/blog/src/app/(blog)/layout.tsx
@@ -105,7 +105,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
{children}
diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx
index ddbf062647..39519d7506 100644
--- a/apps/site/src/components/navigation-wrapper.tsx
+++ b/apps/site/src/components/navigation-wrapper.tsx
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import {
getUtmParams,
+ hasUtmParams,
readStoredUtmParams,
type UtmParams,
writeStoredUtmParams,
@@ -56,27 +57,30 @@ function getUtmMedium(pathname: string) {
export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
const pathname = usePathname();
- const [storedUtmParams, setStoredUtmParams] = useState({
+ const defaultUtmParams = {
utm_source: utm.source,
- });
+ utm_medium: getUtmMedium(pathname),
+ };
+ const [storedUtmParams, setStoredUtmParams] = useState(() => ({
+ ...defaultUtmParams,
+ ...readStoredUtmParams(),
+ }));
useEffect(() => {
const currentUtmParams = getUtmParams(
new URLSearchParams(window.location.search),
);
- if (currentUtmParams.utm_source) {
- setStoredUtmParams(currentUtmParams);
+ if (hasUtmParams(currentUtmParams)) {
+ setStoredUtmParams({ ...defaultUtmParams, ...currentUtmParams });
writeStoredUtmParams(currentUtmParams);
return;
}
- const persistedUtmParams = readStoredUtmParams();
- setStoredUtmParams(
- persistedUtmParams.utm_source
- ? persistedUtmParams
- : { utm_source: utm.source },
- );
+ setStoredUtmParams({
+ ...defaultUtmParams,
+ ...readStoredUtmParams(),
+ });
}, [pathname, utm.source]);
// Determine button variant based on pathname
@@ -91,13 +95,7 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
return (
);
diff --git a/apps/site/src/lib/utm.ts b/apps/site/src/lib/utm.ts
index 0324f24e35..59b39c63c3 100644
--- a/apps/site/src/lib/utm.ts
+++ b/apps/site/src/lib/utm.ts
@@ -2,6 +2,19 @@ export const UTM_STORAGE_KEY = "site_utm_params";
export type UtmParams = Record;
+function sanitizeUtmParams(input: unknown): UtmParams {
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
+ return {};
+ }
+
+ return Object.fromEntries(
+ Object.entries(input).filter(
+ ([key, value]) =>
+ key.startsWith("utm_") && typeof value === "string" && value.length > 0,
+ ),
+ );
+}
+
export function getUtmParams(searchParams: URLSearchParams): UtmParams {
const utmParams: UtmParams = {};
@@ -32,21 +45,37 @@ export function mergeUtmParams(url: URL, utmParams: UtmParams) {
}
export function readStoredUtmParams() {
- const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY);
-
- if (!storedUtmParams) {
+ if (typeof window === "undefined") {
return {};
}
try {
- return JSON.parse(storedUtmParams) as UtmParams;
+ const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY);
+
+ if (!storedUtmParams) {
+ return {};
+ }
+
+ return sanitizeUtmParams(JSON.parse(storedUtmParams));
} catch {
return {};
}
}
export function writeStoredUtmParams(utmParams: UtmParams) {
- if (hasUtmParams(utmParams)) {
- window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(utmParams));
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const validUtmParams = sanitizeUtmParams(utmParams);
+
+ if (!hasUtmParams(validUtmParams)) {
+ return;
+ }
+
+ try {
+ window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(validUtmParams));
+ } catch {
+ // Ignore storage failures in restricted environments.
}
}
diff --git a/packages/ui/src/components/web-navigation.tsx b/packages/ui/src/components/web-navigation.tsx
index 00a9b2d613..a56e87f5cd 100644
--- a/packages/ui/src/components/web-navigation.tsx
+++ b/packages/ui/src/components/web-navigation.tsx
@@ -37,13 +37,7 @@ export interface Link {
interface WebNavigationProps {
links: Link[];
- utm?: {
- source: string;
- medium: string;
- campaign?: string;
- content?: string;
- term?: string;
- };
+ utm?: Record;
buttonVariant?: "ppg" | "orm" | undefined;
}
@@ -57,19 +51,17 @@ function buildConsoleHref(
const href = new URL(`https://console.prisma.io${pathname}`);
- href.searchParams.set("utm_source", utm.source);
- href.searchParams.set("utm_medium", utm.medium);
- href.searchParams.set(
- "utm_campaign",
- utm.campaign || (pathname === "/login" ? "login" : "signup"),
- );
-
- if (utm.content) {
- href.searchParams.set("utm_content", utm.content);
+ for (const [key, value] of Object.entries(utm)) {
+ if (key.startsWith("utm_") && value) {
+ href.searchParams.set(key, value);
+ }
}
- if (utm.term) {
- href.searchParams.set("utm_term", utm.term);
+ if (!href.searchParams.has("utm_campaign")) {
+ href.searchParams.set(
+ "utm_campaign",
+ pathname === "/login" ? "login" : "signup",
+ );
}
return href.toString();
From 1d456801f954e1f8c990017b9c3cf8522c0c4707 Mon Sep 17 00:00:00 2001
From: Mike Hartington
Date: Tue, 7 Apr 2026 10:16:32 -0400
Subject: [PATCH 4/8] fix(site): apply persisted utms to console links
Extend the site UTM persistence handler to rewrite console.prisma.io links and simplify nav UTM resolution so page-level CTAs inherit the stored campaign parameters.
Made-with: Cursor
---
.../src/components/navigation-wrapper.tsx | 32 +++++--------------
apps/site/src/components/utm-persistence.tsx | 17 +++++++---
apps/site/src/lib/utm.ts | 14 ++++++++
3 files changed, 35 insertions(+), 28 deletions(-)
diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx
index 39519d7506..6c3482d6e4 100644
--- a/apps/site/src/components/navigation-wrapper.tsx
+++ b/apps/site/src/components/navigation-wrapper.tsx
@@ -2,14 +2,11 @@
import { WebNavigation } from "@prisma-docs/ui/components/web-navigation";
import { Footer } from "@prisma-docs/ui/components/footer";
-import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import {
getUtmParams,
- hasUtmParams,
readStoredUtmParams,
type UtmParams,
- writeStoredUtmParams,
} from "@/lib/utm";
interface Link {
@@ -61,27 +58,14 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
utm_source: utm.source,
utm_medium: getUtmMedium(pathname),
};
- const [storedUtmParams, setStoredUtmParams] = useState(() => ({
- ...defaultUtmParams,
- ...readStoredUtmParams(),
- }));
-
- useEffect(() => {
- const currentUtmParams = getUtmParams(
- new URLSearchParams(window.location.search),
- );
-
- if (hasUtmParams(currentUtmParams)) {
- setStoredUtmParams({ ...defaultUtmParams, ...currentUtmParams });
- writeStoredUtmParams(currentUtmParams);
- return;
- }
-
- setStoredUtmParams({
- ...defaultUtmParams,
- ...readStoredUtmParams(),
- });
- }, [pathname, utm.source]);
+ const storedUtmParams: UtmParams =
+ typeof window === "undefined"
+ ? defaultUtmParams
+ : {
+ ...defaultUtmParams,
+ ...readStoredUtmParams(),
+ ...getUtmParams(new URLSearchParams(window.location.search)),
+ };
// Determine button variant based on pathname
const getButtonVariant = (): ColorType => {
diff --git a/apps/site/src/components/utm-persistence.tsx b/apps/site/src/components/utm-persistence.tsx
index 79035116c9..dd5c504ab3 100644
--- a/apps/site/src/components/utm-persistence.tsx
+++ b/apps/site/src/components/utm-persistence.tsx
@@ -3,10 +3,12 @@
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import {
+ CONSOLE_HOST,
getUtmParams,
hasUtmParams,
mergeUtmParams,
readStoredUtmParams,
+ replaceUtmParams,
writeStoredUtmParams,
} from "@/lib/utm";
@@ -59,7 +61,6 @@ export function UtmPersistence() {
href.startsWith("#") ||
href.startsWith("mailto:") ||
href.startsWith("tel:") ||
- anchor.target === "_blank" ||
anchor.hasAttribute("download")
) {
return;
@@ -72,18 +73,26 @@ export function UtmPersistence() {
}
const targetUrl = new URL(anchor.href, window.location.href);
+ const isInternalLink = targetUrl.origin === window.location.origin;
+ const isConsoleLink = targetUrl.hostname === CONSOLE_HOST;
- if (targetUrl.origin !== window.location.origin) {
+ if (!isInternalLink && !isConsoleLink) {
return;
}
- if (!mergeUtmParams(targetUrl, storedUtmParams)) {
+ const updated = isConsoleLink
+ ? replaceUtmParams(targetUrl, storedUtmParams)
+ : mergeUtmParams(targetUrl, storedUtmParams);
+
+ if (!updated) {
return;
}
anchor.setAttribute(
"href",
- `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`,
+ isInternalLink
+ ? `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`
+ : targetUrl.toString(),
);
}
diff --git a/apps/site/src/lib/utm.ts b/apps/site/src/lib/utm.ts
index 59b39c63c3..efcc875a26 100644
--- a/apps/site/src/lib/utm.ts
+++ b/apps/site/src/lib/utm.ts
@@ -1,4 +1,5 @@
export const UTM_STORAGE_KEY = "site_utm_params";
+export const CONSOLE_HOST = "console.prisma.io";
export type UtmParams = Record;
@@ -44,6 +45,19 @@ export function mergeUtmParams(url: URL, utmParams: UtmParams) {
return updated;
}
+export function replaceUtmParams(url: URL, utmParams: UtmParams) {
+ let updated = false;
+
+ for (const [key, value] of Object.entries(utmParams)) {
+ if (url.searchParams.get(key) !== value) {
+ url.searchParams.set(key, value);
+ updated = true;
+ }
+ }
+
+ return updated;
+}
+
export function readStoredUtmParams() {
if (typeof window === "undefined") {
return {};
From 8e586db24e1b7dbc1666d9c9581180cd00c9b73a Mon Sep 17 00:00:00 2001
From: Mike Hartington
Date: Tue, 7 Apr 2026 10:49:19 -0400
Subject: [PATCH 5/8] fix(site): preserve exact utm payloads
Use the incoming UTM set exactly when one is present, fall back to defaults only when no UTM exists, and bring the same persistence behavior to the blog shell.
Made-with: Cursor
---
apps/blog/src/app/(blog)/layout.tsx | 8 +-
.../src/components/navigation-wrapper.tsx | 49 +++++++++
apps/blog/src/components/utm-persistence.tsx | 98 +++++++++++++++++
apps/blog/src/lib/utm.ts | 101 ++++++++++++++++++
.../src/components/navigation-wrapper.tsx | 17 ++-
apps/site/src/components/utm-persistence.tsx | 56 +++++-----
apps/site/src/lib/utm.ts | 26 +++--
packages/ui/src/components/web-navigation.tsx | 9 +-
8 files changed, 309 insertions(+), 55 deletions(-)
create mode 100644 apps/blog/src/components/navigation-wrapper.tsx
create mode 100644 apps/blog/src/components/utm-persistence.tsx
create mode 100644 apps/blog/src/lib/utm.ts
diff --git a/apps/blog/src/app/(blog)/layout.tsx b/apps/blog/src/app/(blog)/layout.tsx
index c43c9e6aa0..9e69c85883 100644
--- a/apps/blog/src/app/(blog)/layout.tsx
+++ b/apps/blog/src/app/(blog)/layout.tsx
@@ -1,6 +1,7 @@
-import { WebNavigation } from "@prisma-docs/ui/components/web-navigation";
import { Footer } from "@prisma-docs/ui/components/footer";
import { ThemeProvider } from "@prisma-docs/ui/components/theme-provider";
+import { NavigationWrapper } from "@/components/navigation-wrapper";
+import { UtmPersistence } from "@/components/utm-persistence";
export function baseOptions() {
return {
nav: {
@@ -103,9 +104,10 @@ export function baseOptions() {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
-
+
{children}
diff --git a/apps/blog/src/components/navigation-wrapper.tsx b/apps/blog/src/components/navigation-wrapper.tsx
new file mode 100644
index 0000000000..54397ae614
--- /dev/null
+++ b/apps/blog/src/components/navigation-wrapper.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { WebNavigation } from "@prisma-docs/ui/components/web-navigation";
+import { getUtmParams, hasUtmParams, type UtmParams } from "@/lib/utm";
+
+interface Link {
+ text: string;
+ external?: boolean;
+ url?: string;
+ icon?: string;
+ desc?: string;
+ col?: number;
+ sub?: Array<{
+ text: string;
+ external?: boolean;
+ url: string;
+ icon?: string;
+ desc?: string;
+ }>;
+}
+
+interface NavigationWrapperProps {
+ links: Link[];
+ utm: {
+ source: string;
+ medium: string;
+ };
+}
+
+export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
+ const defaultUtmParams = {
+ utm_source: utm.source,
+ utm_medium: utm.medium,
+ };
+ const currentUtmParams: UtmParams =
+ typeof window === "undefined"
+ ? {}
+ : getUtmParams(new URLSearchParams(window.location.search));
+ const hasExactUtm = hasUtmParams(currentUtmParams);
+ const resolvedUtmParams = hasExactUtm ? currentUtmParams : defaultUtmParams;
+
+ return (
+
+ );
+}
diff --git a/apps/blog/src/components/utm-persistence.tsx b/apps/blog/src/components/utm-persistence.tsx
new file mode 100644
index 0000000000..4d2b80d60c
--- /dev/null
+++ b/apps/blog/src/components/utm-persistence.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { useEffect } from "react";
+import { usePathname, useRouter } from "next/navigation";
+import {
+ clearStoredUtmParams,
+ CONSOLE_HOST,
+ getUtmParams,
+ hasUtmParams,
+ syncUtmParams,
+ writeStoredUtmParams,
+} from "@/lib/utm";
+
+export function UtmPersistence() {
+ const pathname = usePathname();
+ const router = useRouter();
+
+ useEffect(() => {
+ const currentUtmParams = getUtmParams(
+ new URLSearchParams(window.location.search),
+ );
+
+ if (hasUtmParams(currentUtmParams)) {
+ writeStoredUtmParams(currentUtmParams);
+ return;
+ }
+
+ clearStoredUtmParams();
+ }, [pathname]);
+
+ useEffect(() => {
+ function handleClick(event: MouseEvent) {
+ if (event.defaultPrevented || event.button !== 0) {
+ return;
+ }
+
+ const anchor = (event.target as HTMLElement).closest(
+ "a[href]",
+ );
+
+ if (!anchor) {
+ return;
+ }
+
+ const href = anchor.getAttribute("href");
+
+ if (
+ !href ||
+ href.startsWith("#") ||
+ href.startsWith("mailto:") ||
+ href.startsWith("tel:") ||
+ anchor.hasAttribute("download")
+ ) {
+ return;
+ }
+
+ const activeUtmParams = getUtmParams(
+ new URLSearchParams(window.location.search),
+ );
+
+ if (!hasUtmParams(activeUtmParams)) {
+ return;
+ }
+
+ const targetUrl = new URL(anchor.href, window.location.href);
+ const isInternalLink = targetUrl.origin === window.location.origin;
+ const isConsoleLink = targetUrl.hostname === CONSOLE_HOST;
+
+ if (!isInternalLink && !isConsoleLink) {
+ return;
+ }
+
+ if (!syncUtmParams(targetUrl, activeUtmParams)) {
+ return;
+ }
+
+ const nextHref = `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`;
+ const isModifiedClick =
+ event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
+
+ if (isInternalLink && anchor.target !== "_blank" && !isModifiedClick) {
+ event.preventDefault();
+ router.push(nextHref);
+ return;
+ }
+
+ anchor.setAttribute(
+ "href",
+ isInternalLink ? nextHref : targetUrl.toString(),
+ );
+ }
+
+ document.addEventListener("click", handleClick, true);
+ return () => document.removeEventListener("click", handleClick, true);
+ }, [router]);
+
+ return null;
+}
diff --git a/apps/blog/src/lib/utm.ts b/apps/blog/src/lib/utm.ts
new file mode 100644
index 0000000000..8e79844c15
--- /dev/null
+++ b/apps/blog/src/lib/utm.ts
@@ -0,0 +1,101 @@
+export const UTM_STORAGE_KEY = "blog_utm_params";
+export const CONSOLE_HOST = "console.prisma.io";
+
+export type UtmParams = Record;
+
+function sanitizeUtmParams(input: unknown): UtmParams {
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
+ return {};
+ }
+
+ return Object.fromEntries(
+ Object.entries(input).filter(
+ ([key, value]) =>
+ key.startsWith("utm_") && typeof value === "string" && value.length > 0,
+ ),
+ );
+}
+
+export function getUtmParams(searchParams: URLSearchParams): UtmParams {
+ const utmParams: UtmParams = {};
+
+ for (const [key, value] of searchParams.entries()) {
+ if (key.startsWith("utm_") && value) {
+ utmParams[key] = value;
+ }
+ }
+
+ return utmParams;
+}
+
+export function hasUtmParams(utmParams: UtmParams) {
+ return Object.keys(utmParams).length > 0;
+}
+
+export function syncUtmParams(url: URL, utmParams: UtmParams) {
+ let updated = false;
+
+ for (const key of Array.from(url.searchParams.keys())) {
+ if (key.startsWith("utm_") && !(key in utmParams)) {
+ url.searchParams.delete(key);
+ updated = true;
+ }
+ }
+
+ for (const [key, value] of Object.entries(utmParams)) {
+ if (url.searchParams.get(key) !== value) {
+ url.searchParams.set(key, value);
+ updated = true;
+ }
+ }
+
+ return updated;
+}
+
+export function readStoredUtmParams() {
+ if (typeof window === "undefined") {
+ return {};
+ }
+
+ try {
+ const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY);
+
+ if (!storedUtmParams) {
+ return {};
+ }
+
+ return sanitizeUtmParams(JSON.parse(storedUtmParams));
+ } catch {
+ return {};
+ }
+}
+
+export function writeStoredUtmParams(utmParams: UtmParams) {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const validUtmParams = sanitizeUtmParams(utmParams);
+
+ if (!hasUtmParams(validUtmParams)) {
+ return;
+ }
+
+ try {
+ window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(validUtmParams));
+ } catch {
+ // Ignore storage failures in restricted environments.
+ }
+}
+
+export function clearStoredUtmParams() {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.sessionStorage.removeItem(UTM_STORAGE_KEY);
+ } catch {
+ // Ignore storage failures in restricted environments.
+ }
+}
diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx
index 6c3482d6e4..f438bfe2d9 100644
--- a/apps/site/src/components/navigation-wrapper.tsx
+++ b/apps/site/src/components/navigation-wrapper.tsx
@@ -5,7 +5,7 @@ import { Footer } from "@prisma-docs/ui/components/footer";
import { usePathname } from "next/navigation";
import {
getUtmParams,
- readStoredUtmParams,
+ hasUtmParams,
type UtmParams,
} from "@/lib/utm";
@@ -58,14 +58,12 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
utm_source: utm.source,
utm_medium: getUtmMedium(pathname),
};
- const storedUtmParams: UtmParams =
+ const currentUtmParams: UtmParams =
typeof window === "undefined"
- ? defaultUtmParams
- : {
- ...defaultUtmParams,
- ...readStoredUtmParams(),
- ...getUtmParams(new URLSearchParams(window.location.search)),
- };
+ ? {}
+ : getUtmParams(new URLSearchParams(window.location.search));
+ const hasExactUtm = hasUtmParams(currentUtmParams);
+ const resolvedUtmParams = hasExactUtm ? currentUtmParams : defaultUtmParams;
// Determine button variant based on pathname
const getButtonVariant = (): ColorType => {
@@ -79,7 +77,8 @@ export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
return (
);
diff --git a/apps/site/src/components/utm-persistence.tsx b/apps/site/src/components/utm-persistence.tsx
index dd5c504ab3..d0c1c90c76 100644
--- a/apps/site/src/components/utm-persistence.tsx
+++ b/apps/site/src/components/utm-persistence.tsx
@@ -1,19 +1,19 @@
"use client";
import { useEffect } from "react";
-import { usePathname } from "next/navigation";
+import { usePathname, useRouter } from "next/navigation";
import {
+ clearStoredUtmParams,
CONSOLE_HOST,
getUtmParams,
hasUtmParams,
- mergeUtmParams,
- readStoredUtmParams,
- replaceUtmParams,
+ syncUtmParams,
writeStoredUtmParams,
} from "@/lib/utm";
export function UtmPersistence() {
const pathname = usePathname();
+ const router = useRouter();
useEffect(() => {
const currentUtmParams = getUtmParams(
@@ -25,27 +25,15 @@ export function UtmPersistence() {
return;
}
- const storedUtmParams = readStoredUtmParams();
-
- if (!hasUtmParams(storedUtmParams)) {
- return;
- }
-
- const currentUrl = new URL(window.location.href);
-
- if (!mergeUtmParams(currentUrl, storedUtmParams)) {
- return;
- }
-
- window.history.replaceState(
- window.history.state,
- "",
- `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`,
- );
+ clearStoredUtmParams();
}, [pathname]);
useEffect(() => {
function handleClick(event: MouseEvent) {
+ if (event.defaultPrevented || event.button !== 0) {
+ return;
+ }
+
const anchor = (event.target as HTMLElement).closest(
"a[href]",
);
@@ -66,9 +54,11 @@ export function UtmPersistence() {
return;
}
- const storedUtmParams = readStoredUtmParams();
+ const activeUtmParams = getUtmParams(
+ new URLSearchParams(window.location.search),
+ );
- if (!hasUtmParams(storedUtmParams)) {
+ if (!hasUtmParams(activeUtmParams)) {
return;
}
@@ -80,25 +70,31 @@ export function UtmPersistence() {
return;
}
- const updated = isConsoleLink
- ? replaceUtmParams(targetUrl, storedUtmParams)
- : mergeUtmParams(targetUrl, storedUtmParams);
+ const updated = syncUtmParams(targetUrl, activeUtmParams);
if (!updated) {
return;
}
+ const nextHref = `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`;
+ const isModifiedClick =
+ event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
+
+ if (isInternalLink && anchor.target !== "_blank" && !isModifiedClick) {
+ event.preventDefault();
+ router.push(nextHref);
+ return;
+ }
+
anchor.setAttribute(
"href",
- isInternalLink
- ? `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`
- : targetUrl.toString(),
+ isInternalLink ? nextHref : targetUrl.toString(),
);
}
document.addEventListener("click", handleClick, true);
return () => document.removeEventListener("click", handleClick, true);
- }, []);
+ }, [router]);
return null;
}
diff --git a/apps/site/src/lib/utm.ts b/apps/site/src/lib/utm.ts
index efcc875a26..b3877d06c6 100644
--- a/apps/site/src/lib/utm.ts
+++ b/apps/site/src/lib/utm.ts
@@ -32,22 +32,16 @@ export function hasUtmParams(utmParams: UtmParams) {
return Object.keys(utmParams).length > 0;
}
-export function mergeUtmParams(url: URL, utmParams: UtmParams) {
+export function syncUtmParams(url: URL, utmParams: UtmParams) {
let updated = false;
- for (const [key, value] of Object.entries(utmParams)) {
- if (!url.searchParams.has(key)) {
- url.searchParams.set(key, value);
+ for (const key of Array.from(url.searchParams.keys())) {
+ if (key.startsWith("utm_") && !(key in utmParams)) {
+ url.searchParams.delete(key);
updated = true;
}
}
- return updated;
-}
-
-export function replaceUtmParams(url: URL, utmParams: UtmParams) {
- let updated = false;
-
for (const [key, value] of Object.entries(utmParams)) {
if (url.searchParams.get(key) !== value) {
url.searchParams.set(key, value);
@@ -93,3 +87,15 @@ export function writeStoredUtmParams(utmParams: UtmParams) {
// Ignore storage failures in restricted environments.
}
}
+
+export function clearStoredUtmParams() {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.sessionStorage.removeItem(UTM_STORAGE_KEY);
+ } catch {
+ // Ignore storage failures in restricted environments.
+ }
+}
diff --git a/packages/ui/src/components/web-navigation.tsx b/packages/ui/src/components/web-navigation.tsx
index a56e87f5cd..2ae536e5dc 100644
--- a/packages/ui/src/components/web-navigation.tsx
+++ b/packages/ui/src/components/web-navigation.tsx
@@ -38,12 +38,14 @@ export interface Link {
interface WebNavigationProps {
links: Link[];
utm?: Record;
+ preserveExactUtm?: boolean;
buttonVariant?: "ppg" | "orm" | undefined;
}
function buildConsoleHref(
pathname: "/login" | "/sign-up",
utm?: WebNavigationProps["utm"],
+ preserveExactUtm = false,
) {
if (!utm) {
return `https://console.prisma.io${pathname}`;
@@ -57,7 +59,7 @@ function buildConsoleHref(
}
}
- if (!href.searchParams.has("utm_campaign")) {
+ if (!preserveExactUtm && !href.searchParams.has("utm_campaign")) {
href.searchParams.set(
"utm_campaign",
pathname === "/login" ? "login" : "signup",
@@ -70,11 +72,12 @@ function buildConsoleHref(
export function WebNavigation({
links,
utm,
+ preserveExactUtm = false,
buttonVariant = "ppg",
}: WebNavigationProps) {
const [mobileView, setMobileView] = useState(false);
- const loginHref = buildConsoleHref("/login", utm);
- const signupHref = buildConsoleHref("/sign-up", utm);
+ const loginHref = buildConsoleHref("/login", utm, preserveExactUtm);
+ const signupHref = buildConsoleHref("/sign-up", utm, preserveExactUtm);
useEffect(() => {
if (mobileView) {
From c0a1870402eec0f8133a5cc39e10add465b64d52 Mon Sep 17 00:00:00 2001
From: Mike Hartington
Date: Tue, 7 Apr 2026 10:58:00 -0400
Subject: [PATCH 6/8] fix(site): hydrate homepage console ctas safely
Avoid nav hydration mismatches by deferring exact UTM resolution until after mount and apply the same exact-or-default UTM handling to the homepage create database CTAs.
Made-with: Cursor
---
.../src/components/navigation-wrapper.tsx | 17 ++++---
apps/site/src/app/(index)/page.tsx | 23 ++++++---
.../src/components/console-cta-button.tsx | 51 +++++++++++++++++++
.../src/components/navigation-wrapper.tsx | 17 ++++---
4 files changed, 90 insertions(+), 18 deletions(-)
create mode 100644 apps/site/src/components/console-cta-button.tsx
diff --git a/apps/blog/src/components/navigation-wrapper.tsx b/apps/blog/src/components/navigation-wrapper.tsx
index 54397ae614..8668683646 100644
--- a/apps/blog/src/components/navigation-wrapper.tsx
+++ b/apps/blog/src/components/navigation-wrapper.tsx
@@ -1,6 +1,7 @@
"use client";
import { WebNavigation } from "@prisma-docs/ui/components/web-navigation";
+import { useEffect, useState } from "react";
import { getUtmParams, hasUtmParams, type UtmParams } from "@/lib/utm";
interface Link {
@@ -28,22 +29,26 @@ interface NavigationWrapperProps {
}
export function NavigationWrapper({ links, utm }: NavigationWrapperProps) {
+ const [mounted, setMounted] = useState(false);
const defaultUtmParams = {
utm_source: utm.source,
utm_medium: utm.medium,
};
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
const currentUtmParams: UtmParams =
- typeof window === "undefined"
- ? {}
- : getUtmParams(new URLSearchParams(window.location.search));
- const hasExactUtm = hasUtmParams(currentUtmParams);
- const resolvedUtmParams = hasExactUtm ? currentUtmParams : defaultUtmParams;
+ mounted ? getUtmParams(new URLSearchParams(window.location.search)) : {};
+ const preserveExactUtm = hasUtmParams(currentUtmParams);
+ const resolvedUtmParams = preserveExactUtm ? currentUtmParams : defaultUtmParams;
return (
);
}
diff --git a/apps/site/src/app/(index)/page.tsx b/apps/site/src/app/(index)/page.tsx
index e030b61f14..f39f442cf1 100644
--- a/apps/site/src/app/(index)/page.tsx
+++ b/apps/site/src/app/(index)/page.tsx
@@ -5,6 +5,7 @@ import { Button } from "@prisma/eclipse";
import { CopyCode } from "@/components/homepage/copy-btn";
import { Bento } from "@/components/homepage/bento";
import { CardSection } from "@/components/homepage/card-section/card-section";
+import { ConsoleCtaButton } from "@/components/console-cta-button";
import review from "../../data/homepage.json";
import Testimonials from "../../components/homepage/testimonials";
@@ -114,9 +115,14 @@ export default function SiteHome() {
ship faster.
-
+
$
@@ -246,14 +252,19 @@ export default function SiteHome() {
-
+