From 9c853bfd8dceac6f7d829ccdda3905708942411e Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 15 Apr 2026 11:16:03 +0200 Subject: [PATCH 1/9] fix(ui): preserve UTM params on logo link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The logo href was hardcoded to https://www.prisma.io without UTM params. Now it includes them when present, using a shared buildHref helper — same pattern as the Login/Signup buttons. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ui/src/components/web-navigation.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/web-navigation.tsx b/packages/ui/src/components/web-navigation.tsx index 1b03b10f20..e1c12140a3 100644 --- a/packages/ui/src/components/web-navigation.tsx +++ b/packages/ui/src/components/web-navigation.tsx @@ -42,6 +42,17 @@ interface WebNavigationProps { buttonVariant?: "ppg" | "orm" | undefined; } +function buildHref(base: string, utm?: WebNavigationProps["utm"]) { + if (!utm) return base; + const url = new URL(base); + for (const [key, value] of Object.entries(utm)) { + if (key.startsWith("utm_") && value) { + url.searchParams.set(key, value); + } + } + return url.toString(); +} + function buildConsoleHref( pathname: "/login" | "/sign-up", utm?: WebNavigationProps["utm"], @@ -78,6 +89,9 @@ export function WebNavigation({ const [mobileView, setMobileView] = useState(false); const loginHref = buildConsoleHref("/login", utm, preserveExactUtm); const signupHref = buildConsoleHref("/sign-up", utm, preserveExactUtm); + const logoHref = preserveExactUtm + ? buildHref("https://www.prisma.io", utm) + : "https://www.prisma.io"; useEffect(() => { if (mobileView) { @@ -94,7 +108,7 @@ export function WebNavigation({ {Logo} From ce948c1b8448ab8157cbd2a7e9436b3af8665156 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 15 Apr 2026 11:17:29 +0200 Subject: [PATCH 2/9] fix(ui): preserve UTM params on all navigation links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildHref now handles both relative and absolute URLs. When UTM params are present, all nav links (logo, top-level items, dropdown sub-items, mobile menu) get UTM params baked into the href — not just the Login/Signup buttons. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ui/src/components/web-navigation.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/web-navigation.tsx b/packages/ui/src/components/web-navigation.tsx index e1c12140a3..53be3ca00c 100644 --- a/packages/ui/src/components/web-navigation.tsx +++ b/packages/ui/src/components/web-navigation.tsx @@ -44,13 +44,14 @@ interface WebNavigationProps { function buildHref(base: string, utm?: WebNavigationProps["utm"]) { if (!utm) return base; - const url = new URL(base); + const isAbsolute = base.startsWith("http"); + const url = isAbsolute ? new URL(base) : new URL(base, "https://n.co"); for (const [key, value] of Object.entries(utm)) { if (key.startsWith("utm_") && value) { url.searchParams.set(key, value); } } - return url.toString(); + return isAbsolute ? url.toString() : `${url.pathname}${url.search}`; } function buildConsoleHref( @@ -92,6 +93,16 @@ export function WebNavigation({ const logoHref = preserveExactUtm ? buildHref("https://www.prisma.io", utm) : "https://www.prisma.io"; + const resolvedLinks = preserveExactUtm + ? links.map((link) => ({ + ...link, + url: link.url && !link.external ? buildHref(link.url, utm) : link.url, + sub: link.sub?.map((sub) => ({ + ...sub, + url: sub.external ? sub.url : buildHref(sub.url, utm), + })), + })) + : links; useEffect(() => { if (mobileView) { @@ -114,7 +125,7 @@ export function WebNavigation({
- {links.map((link) => + {resolvedLinks.map((link) => link.url ? ( @@ -169,7 +180,7 @@ export function WebNavigation({ {mobileView && ( Date: Wed, 15 Apr 2026 11:28:28 +0200 Subject: [PATCH 3/9] feat(docs): add UTM param persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs app had no UTM persistence — clicking any sidebar or content link from a page with UTM params (e.g. /docs/orm?utm_term=devrel) would lose the params. Now uses the same pattern as site and blog: - Store UTM params in sessionStorage on landing - Intercept link clicks and append stored UTM params - Only use router.push() for docs-internal paths (/docs/*) - Full page navigation for cross-app links Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/docs/src/components/provider.tsx | 2 + apps/docs/src/components/utm-persistence.tsx | 116 +++++++++++++++++++ apps/docs/src/lib/utm.ts | 101 ++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 apps/docs/src/components/utm-persistence.tsx create mode 100644 apps/docs/src/lib/utm.ts diff --git a/apps/docs/src/components/provider.tsx b/apps/docs/src/components/provider.tsx index e7eb276d32..4b0ec89ed4 100644 --- a/apps/docs/src/components/provider.tsx +++ b/apps/docs/src/components/provider.tsx @@ -7,6 +7,7 @@ import type { ReactNode } from "react"; import { source } from "@/lib/source"; import { TreeContextProvider } from "fumadocs-ui/contexts/tree"; import { TrackingProvider } from "@/components/tracking-provider"; +import { UtmPersistence } from "@/components/utm-persistence"; const KAPA_INTEGRATION_ID = "1b51bb03-43cc-4ef4-95f1-93288a91b560"; @@ -29,6 +30,7 @@ export function Provider({ children }: { children: ReactNode }) { }} > + {children} diff --git a/apps/docs/src/components/utm-persistence.tsx b/apps/docs/src/components/utm-persistence.tsx new file mode 100644 index 0000000000..053c3bb8af --- /dev/null +++ b/apps/docs/src/components/utm-persistence.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { DOCS_PREFIX } from "@/lib/urls"; +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 isDocsPath = + targetUrl.pathname === DOCS_PREFIX || + targetUrl.pathname.startsWith(`${DOCS_PREFIX}/`); + const isModifiedClick = + event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + + if ( + isInternalLink && + isDocsPath && + anchor.target !== "_blank" && + !isModifiedClick + ) { + const internalPathname = + targetUrl.pathname === DOCS_PREFIX + ? "/" + : targetUrl.pathname.replace( + new RegExp(`^${DOCS_PREFIX}(?:/|$)`), + "/", + ); + event.preventDefault(); + router.push( + `${internalPathname}${targetUrl.search}${targetUrl.hash}`, + ); + 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/docs/src/lib/utm.ts b/apps/docs/src/lib/utm.ts new file mode 100644 index 0000000000..9d6dc4f1f8 --- /dev/null +++ b/apps/docs/src/lib/utm.ts @@ -0,0 +1,101 @@ +export const UTM_STORAGE_KEY = "docs_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. + } +} From 34077c790af245caef1ce4911f1c2e97776d25c8 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 15 Apr 2026 11:33:37 +0200 Subject: [PATCH 4/9] refactor(ui): centralize UTM persistence into shared package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved utm.ts and utm-persistence.tsx into @prisma-docs/ui so all three apps (site, blog, docs) share a single implementation. Each app's local utm-persistence.tsx is now a thin wrapper that passes its config (storageKey, basePath or proxiedPaths) to the shared component. The local utm.ts files are deleted — consumers import directly from @prisma-docs/ui/lib/utm. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/navigation-wrapper.tsx | 2 +- apps/blog/src/components/utm-persistence.tsx | 115 +------------- apps/docs/src/components/utm-persistence.tsx | 115 +------------- apps/docs/src/lib/utm.ts | 101 ------------- .../src/components/console-cta-button.tsx | 2 +- .../src/components/navigation-wrapper.tsx | 2 +- apps/site/src/components/utm-persistence.tsx | 117 ++------------- apps/site/src/lib/utm.ts | 101 ------------- .../ui/src/components/utm-persistence.tsx | 141 ++++++++++++++++++ {apps/blog => packages/ui}/src/lib/utm.ts | 13 +- 10 files changed, 167 insertions(+), 542 deletions(-) delete mode 100644 apps/docs/src/lib/utm.ts delete mode 100644 apps/site/src/lib/utm.ts create mode 100644 packages/ui/src/components/utm-persistence.tsx rename {apps/blog => packages/ui}/src/lib/utm.ts (82%) diff --git a/apps/blog/src/components/navigation-wrapper.tsx b/apps/blog/src/components/navigation-wrapper.tsx index 8668683646..4a5887ce6f 100644 --- a/apps/blog/src/components/navigation-wrapper.tsx +++ b/apps/blog/src/components/navigation-wrapper.tsx @@ -2,7 +2,7 @@ import { WebNavigation } from "@prisma-docs/ui/components/web-navigation"; import { useEffect, useState } from "react"; -import { getUtmParams, hasUtmParams, type UtmParams } from "@/lib/utm"; +import { getUtmParams, hasUtmParams, type UtmParams } from "@prisma-docs/ui/lib/utm"; interface Link { text: string; diff --git a/apps/blog/src/components/utm-persistence.tsx b/apps/blog/src/components/utm-persistence.tsx index d9c041b659..933bc34327 100644 --- a/apps/blog/src/components/utm-persistence.tsx +++ b/apps/blog/src/components/utm-persistence.tsx @@ -1,116 +1,9 @@ "use client"; -import { useEffect } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { BLOG_PREFIX } from "@/lib/url"; -import { - clearStoredUtmParams, - CONSOLE_HOST, - getUtmParams, - hasUtmParams, - syncUtmParams, - writeStoredUtmParams, -} from "@/lib/utm"; +import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence"; 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 isBlogPath = - targetUrl.pathname === BLOG_PREFIX || - targetUrl.pathname.startsWith(`${BLOG_PREFIX}/`); - const isModifiedClick = - event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; - - if ( - isInternalLink && - isBlogPath && - anchor.target !== "_blank" && - !isModifiedClick - ) { - const internalPathname = - targetUrl.pathname === BLOG_PREFIX - ? "/" - : targetUrl.pathname.replace( - new RegExp(`^${BLOG_PREFIX}(?:/|$)`), - "/", - ); - event.preventDefault(); - router.push( - `${internalPathname}${targetUrl.search}${targetUrl.hash}`, - ); - return; - } - - anchor.setAttribute( - "href", - isInternalLink ? nextHref : targetUrl.toString(), - ); - } - - document.addEventListener("click", handleClick, true); - return () => document.removeEventListener("click", handleClick, true); - }, [router]); - - return null; + return ( + + ); } diff --git a/apps/docs/src/components/utm-persistence.tsx b/apps/docs/src/components/utm-persistence.tsx index 053c3bb8af..1ac5a3834e 100644 --- a/apps/docs/src/components/utm-persistence.tsx +++ b/apps/docs/src/components/utm-persistence.tsx @@ -1,116 +1,9 @@ "use client"; -import { useEffect } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { DOCS_PREFIX } from "@/lib/urls"; -import { - clearStoredUtmParams, - CONSOLE_HOST, - getUtmParams, - hasUtmParams, - syncUtmParams, - writeStoredUtmParams, -} from "@/lib/utm"; +import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence"; 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 isDocsPath = - targetUrl.pathname === DOCS_PREFIX || - targetUrl.pathname.startsWith(`${DOCS_PREFIX}/`); - const isModifiedClick = - event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; - - if ( - isInternalLink && - isDocsPath && - anchor.target !== "_blank" && - !isModifiedClick - ) { - const internalPathname = - targetUrl.pathname === DOCS_PREFIX - ? "/" - : targetUrl.pathname.replace( - new RegExp(`^${DOCS_PREFIX}(?:/|$)`), - "/", - ); - event.preventDefault(); - router.push( - `${internalPathname}${targetUrl.search}${targetUrl.hash}`, - ); - return; - } - - anchor.setAttribute( - "href", - isInternalLink ? nextHref : targetUrl.toString(), - ); - } - - document.addEventListener("click", handleClick, true); - return () => document.removeEventListener("click", handleClick, true); - }, [router]); - - return null; + return ( + + ); } diff --git a/apps/docs/src/lib/utm.ts b/apps/docs/src/lib/utm.ts deleted file mode 100644 index 9d6dc4f1f8..0000000000 --- a/apps/docs/src/lib/utm.ts +++ /dev/null @@ -1,101 +0,0 @@ -export const UTM_STORAGE_KEY = "docs_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/console-cta-button.tsx b/apps/site/src/components/console-cta-button.tsx index 05a2114dcc..628a46ff7f 100644 --- a/apps/site/src/components/console-cta-button.tsx +++ b/apps/site/src/components/console-cta-button.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Button, type ButtonProps } from "@prisma/eclipse"; -import { getUtmParams, hasUtmParams, type UtmParams } from "@/lib/utm"; +import { getUtmParams, hasUtmParams, type UtmParams } from "@prisma-docs/ui/lib/utm"; interface ConsoleCtaButtonProps extends Omit { consolePath: "/login" | "/sign-up"; diff --git a/apps/site/src/components/navigation-wrapper.tsx b/apps/site/src/components/navigation-wrapper.tsx index 2e330333e2..2a1d6329aa 100644 --- a/apps/site/src/components/navigation-wrapper.tsx +++ b/apps/site/src/components/navigation-wrapper.tsx @@ -8,7 +8,7 @@ import { getUtmParams, hasUtmParams, type UtmParams, -} from "@/lib/utm"; +} from "@prisma-docs/ui/lib/utm"; interface Link { text: string; diff --git a/apps/site/src/components/utm-persistence.tsx b/apps/site/src/components/utm-persistence.tsx index 31c204b494..739978813f 100644 --- a/apps/site/src/components/utm-persistence.tsx +++ b/apps/site/src/components/utm-persistence.tsx @@ -1,113 +1,14 @@ "use client"; -import { useEffect } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { - clearStoredUtmParams, - CONSOLE_HOST, - getUtmParams, - hasUtmParams, - syncUtmParams, - writeStoredUtmParams, -} from "@/lib/utm"; +import { UtmPersistence as SharedUtmPersistence } from "@prisma-docs/ui/components/utm-persistence"; -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 PROXIED_PATHS = ["/docs", "/blog"]; - 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; - } - - 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; - - // Paths proxied to other apps via rewrites — must use full navigation - // so the server-side rewrite kicks in instead of client-side routing. - const isProxiedPath = - targetUrl.pathname === "/docs" || - targetUrl.pathname.startsWith("/docs/") || - targetUrl.pathname === "/blog" || - targetUrl.pathname.startsWith("/blog/"); - - if ( - isInternalLink && - !isProxiedPath && - 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; +export function UtmPersistence() { + return ( + + ); } diff --git a/apps/site/src/lib/utm.ts b/apps/site/src/lib/utm.ts deleted file mode 100644 index b3877d06c6..0000000000 --- a/apps/site/src/lib/utm.ts +++ /dev/null @@ -1,101 +0,0 @@ -export const UTM_STORAGE_KEY = "site_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/packages/ui/src/components/utm-persistence.tsx b/packages/ui/src/components/utm-persistence.tsx new file mode 100644 index 0000000000..60d967b7ea --- /dev/null +++ b/packages/ui/src/components/utm-persistence.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { + clearStoredUtmParams, + CONSOLE_HOST, + getUtmParams, + hasUtmParams, + syncUtmParams, + writeStoredUtmParams, +} from "../lib/utm"; + +interface UtmPersistenceProps { + /** + * The base path this app owns (e.g. "/blog", "/docs"). + * Only paths under this prefix use client-side router.push(). + * Omit for the root app (no basePath). + */ + basePath?: string; + /** + * Paths that are proxied to other apps via server rewrites. + * These always use full page navigation instead of router.push(). + * Only relevant for the root app (no basePath). + */ + proxiedPaths?: string[]; + /** Session storage key for persisting UTM params. */ + storageKey: string; +} + +export function UtmPersistence({ + basePath, + proxiedPaths = [], + storageKey, +}: UtmPersistenceProps) { + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + const currentUtmParams = getUtmParams( + new URLSearchParams(window.location.search), + ); + + if (hasUtmParams(currentUtmParams)) { + writeStoredUtmParams(storageKey, currentUtmParams); + return; + } + + clearStoredUtmParams(storageKey); + }, [pathname, storageKey]); + + 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) { + const canClientRoute = basePath + ? targetUrl.pathname === basePath || + targetUrl.pathname.startsWith(`${basePath}/`) + : !proxiedPaths.some( + (p) => + targetUrl.pathname === p || + targetUrl.pathname.startsWith(`${p}/`), + ); + + if (canClientRoute) { + const internalPathname = basePath + ? targetUrl.pathname === basePath + ? "/" + : targetUrl.pathname.replace( + new RegExp(`^${basePath}(?:/|$)`), + "/", + ) + : targetUrl.pathname; + + event.preventDefault(); + router.push( + `${internalPathname}${targetUrl.search}${targetUrl.hash}`, + ); + return; + } + } + + anchor.setAttribute( + "href", + isInternalLink ? nextHref : targetUrl.toString(), + ); + } + + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, [router, basePath, proxiedPaths]); + + return null; +} diff --git a/apps/blog/src/lib/utm.ts b/packages/ui/src/lib/utm.ts similarity index 82% rename from apps/blog/src/lib/utm.ts rename to packages/ui/src/lib/utm.ts index 8e79844c15..73c7b74a9a 100644 --- a/apps/blog/src/lib/utm.ts +++ b/packages/ui/src/lib/utm.ts @@ -1,4 +1,3 @@ -export const UTM_STORAGE_KEY = "blog_utm_params"; export const CONSOLE_HOST = "console.prisma.io"; export type UtmParams = Record; @@ -52,13 +51,13 @@ export function syncUtmParams(url: URL, utmParams: UtmParams) { return updated; } -export function readStoredUtmParams() { +export function readStoredUtmParams(storageKey: string) { if (typeof window === "undefined") { return {}; } try { - const storedUtmParams = window.sessionStorage.getItem(UTM_STORAGE_KEY); + const storedUtmParams = window.sessionStorage.getItem(storageKey); if (!storedUtmParams) { return {}; @@ -70,7 +69,7 @@ export function readStoredUtmParams() { } } -export function writeStoredUtmParams(utmParams: UtmParams) { +export function writeStoredUtmParams(storageKey: string, utmParams: UtmParams) { if (typeof window === "undefined") { return; } @@ -82,19 +81,19 @@ export function writeStoredUtmParams(utmParams: UtmParams) { } try { - window.sessionStorage.setItem(UTM_STORAGE_KEY, JSON.stringify(validUtmParams)); + window.sessionStorage.setItem(storageKey, JSON.stringify(validUtmParams)); } catch { // Ignore storage failures in restricted environments. } } -export function clearStoredUtmParams() { +export function clearStoredUtmParams(storageKey: string) { if (typeof window === "undefined") { return; } try { - window.sessionStorage.removeItem(UTM_STORAGE_KEY); + window.sessionStorage.removeItem(storageKey); } catch { // Ignore storage failures in restricted environments. } From b27d06e70b1acd0b7ea997555e8ceab62b8d96b8 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 15 Apr 2026 11:39:21 +0200 Subject: [PATCH 5/9] fix(ui): add next as peer dependency for utm-persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared UtmPersistence component imports usePathname and useRouter from next/navigation — next needs to be a peer dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ui/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/ui/package.json b/packages/ui/package.json index c6ff4e0dc3..56aa3c10c9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -34,6 +34,7 @@ "typescript": "catalog:" }, "peerDependencies": { + "next": ">=15", "react": "^19.2.0", "react-dom": "^19.2.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d31c9f567..47c9c94934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -818,6 +818,9 @@ importers: lucide-react: specifier: 'catalog:' version: 0.575.0(react@19.2.4) + next: + specifier: '>=15' + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.2.0 version: 19.2.4 From 282c317db2e17418d211b5d386414bdb9fee8734 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 15 Apr 2026 11:42:19 +0200 Subject: [PATCH 6/9] fix(ui): use catalog version for next peer dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ui/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 56aa3c10c9..d24a89c96b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -34,7 +34,7 @@ "typescript": "catalog:" }, "peerDependencies": { - "next": ">=15", + "next": "catalog:", "react": "^19.2.0", "react-dom": "^19.2.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47c9c94934..64b5b7b111 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -819,7 +819,7 @@ importers: specifier: 'catalog:' version: 0.575.0(react@19.2.4) next: - specifier: '>=15' + specifier: 'catalog:' version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.2.0 From 55fd85f38d24757b1be47da5cff93b955064037f Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 15 Apr 2026 11:44:44 +0200 Subject: [PATCH 7/9] fix(ui): preserve hash fragments in buildHref for relative URLs Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ui/src/components/web-navigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/web-navigation.tsx b/packages/ui/src/components/web-navigation.tsx index 53be3ca00c..cc3188d45e 100644 --- a/packages/ui/src/components/web-navigation.tsx +++ b/packages/ui/src/components/web-navigation.tsx @@ -51,7 +51,7 @@ function buildHref(base: string, utm?: WebNavigationProps["utm"]) { url.searchParams.set(key, value); } } - return isAbsolute ? url.toString() : `${url.pathname}${url.search}`; + return isAbsolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`; } function buildConsoleHref( From feb695dde3ee5bc984ec784bd24af3cec1e4f6e6 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 15 Apr 2026 11:53:31 +0200 Subject: [PATCH 8/9] fix(ui): replace regex with string methods for basePath stripping Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/ui/src/components/utm-persistence.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/utm-persistence.tsx b/packages/ui/src/components/utm-persistence.tsx index 60d967b7ea..ec60dfaf1a 100644 --- a/packages/ui/src/components/utm-persistence.tsx +++ b/packages/ui/src/components/utm-persistence.tsx @@ -113,10 +113,9 @@ export function UtmPersistence({ const internalPathname = basePath ? targetUrl.pathname === basePath ? "/" - : targetUrl.pathname.replace( - new RegExp(`^${basePath}(?:/|$)`), - "/", - ) + : targetUrl.pathname.startsWith(`${basePath}/`) + ? targetUrl.pathname.slice(basePath.length) + : targetUrl.pathname : targetUrl.pathname; event.preventDefault(); From 7f8750f9a970717f883a47d424f7d1f18d9d2746 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 15 Apr 2026 12:11:01 +0200 Subject: [PATCH 9/9] fix(docs): bake UTM params into navbar link hrefs The docs navbar links (Getting Started, ORM, Postgres, etc.) now include UTM params directly in their href when present. This makes params visible on hover and preserves them on middle-click/copy-link, not just relying on the click handler. All navbar links flow through LinkItem, which now uses a useUtmHref hook to read current UTM params and append them to the href. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/docs/src/components/layout/link-item.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/components/layout/link-item.tsx b/apps/docs/src/components/layout/link-item.tsx index e9c910d8c9..4b5ec09f45 100644 --- a/apps/docs/src/components/layout/link-item.tsx +++ b/apps/docs/src/components/layout/link-item.tsx @@ -1,9 +1,32 @@ 'use client'; -import type { ComponentProps, ReactNode } from 'react'; +import { type ComponentProps, type ReactNode, useEffect, useState } from 'react'; import { usePathname } from 'fumadocs-core/framework'; import { isActive, isActiveAny } from '../../lib/urls'; +import { getUtmParams, hasUtmParams } from '@prisma-docs/ui/lib/utm'; import Link from 'fumadocs-core/link'; +function useUtmHref(base: string): string { + const [href, setHref] = useState(base); + useEffect(() => { + const utm = getUtmParams(new URLSearchParams(window.location.search)); + if (!hasUtmParams(utm)) { + setHref(base); + return; + } + try { + const isAbsolute = base.startsWith('http'); + const url = isAbsolute ? new URL(base) : new URL(base, 'https://n.co'); + for (const [key, value] of Object.entries(utm)) { + url.searchParams.set(key, value); + } + setHref(isAbsolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`); + } catch { + setHref(base); + } + }, [base]); + return href; +} + interface Filterable { /** * Restrict where the item is displayed @@ -111,8 +134,10 @@ export function LinkItem({ ? isActiveAny(item.activePaths, pathname) : activeType !== 'none' && isActive(item.url, pathname, activeType === 'nested-url'); + const href = useUtmHref(item.url); + return ( - + {props.children} );