diff --git a/apps/blog/src/app/(blog)/layout.tsx b/apps/blog/src/app/(blog)/layout.tsx
index 54ce2154d2..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,7 +104,8 @@ export function baseOptions() {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
-
+
diff --git a/apps/blog/src/components/navigation-wrapper.tsx b/apps/blog/src/components/navigation-wrapper.tsx
new file mode 100644
index 0000000000..8668683646
--- /dev/null
+++ b/apps/blog/src/components/navigation-wrapper.tsx
@@ -0,0 +1,54 @@
+"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 {
+ 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 [mounted, setMounted] = useState(false);
+ const defaultUtmParams = {
+ utm_source: utm.source,
+ utm_medium: utm.medium,
+ };
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const currentUtmParams: UtmParams =
+ mounted ? getUtmParams(new URLSearchParams(window.location.search)) : {};
+ const preserveExactUtm = hasUtmParams(currentUtmParams);
+ const resolvedUtmParams = preserveExactUtm ? 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/app/(index)/page.tsx b/apps/site/src/app/(index)/page.tsx
index e030b61f14..06016b1a18 100644
--- a/apps/site/src/app/(index)/page.tsx
+++ b/apps/site/src/app/(index)/page.tsx
@@ -5,9 +5,16 @@ 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";
+const INDEX_CTA_DEFAULT_UTM = {
+ utm_source: "website",
+ utm_medium: "index",
+ utm_campaign: "cta",
+} as const;
+
const twoCol = [
{
content: (
@@ -114,9 +121,10 @@ export default function SiteHome() {
ship faster.
-
+
$
@@ -246,14 +254,15 @@ export default function SiteHome() {
-
+