diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx
index 95d1321a2e8..ce6524321c3 100644
--- a/packages/shared/src/components/FeedItemComponent.tsx
+++ b/packages/shared/src/components/FeedItemComponent.tsx
@@ -17,6 +17,7 @@ import { useFeedLayout } from '../hooks';
import { CollectionList } from './cards/collection/CollectionList';
import { FeedItemType } from './cards/common/common';
import { AdGrid } from './cards/ad/AdGrid';
+import { PreferredSearchCard } from './post/preferredSources';
import { AdList } from './cards/ad/AdList';
import { SignalAdList } from './cards/ad/SignalAdList';
import type { AdCardProps } from './cards/ad/common/common';
@@ -489,6 +490,18 @@ function FeedItemComponent({
/>
);
}
+ case FeedItemType.Placeholder: {
+ // An ad position the ad server could not fill renders a grey card.
+ // Offer something of ours in that space instead — never in a slot real
+ // content would have taken.
+ const isAdSlot = typeof item.index === 'number';
+
+ return isAdSlot ? (
+ } />
+ ) : (
+
+ );
+ }
default:
return ;
}
diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx
index 72c2d6d9b12..f9df24c30d6 100644
--- a/packages/shared/src/components/post/PostContent.tsx
+++ b/packages/shared/src/components/post/PostContent.tsx
@@ -87,6 +87,7 @@ export function PostContentRaw({
isBannerVisible,
isPostPage,
getWidgetRailAd,
+ widgetsLeading,
contentLeading,
renderSummarySegments,
aboveComments,
@@ -301,6 +302,7 @@ export function PostContentRaw({
origin={origin}
onCopyPostLink={onCopyPostLink}
getRailAd={getWidgetRailAd}
+ leading={widgetsLeading}
/>
);
diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx
index f42748fe9ea..1385685b407 100644
--- a/packages/shared/src/components/post/PostWidgets.tsx
+++ b/packages/shared/src/components/post/PostWidgets.tsx
@@ -17,6 +17,7 @@ import { PostSidebarAdWidget } from './PostSidebarAdWidget';
import { FeaturedArchives } from '../widgets/FeaturedArchives';
import { MentionedToolsWidget } from '../brand/MentionedToolsWidget';
import { PostSignupWidget } from './PostSignupWidget';
+import { PreferGoogleSourceAction } from './preferredSources';
import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget';
const UserEntityCard = dynamic(
@@ -67,6 +68,8 @@ export type PostWidgetsProps = Omit &
hideToc?: boolean;
/** Renders a slot after the widget at each position. */
getRailAd?: (position: PostWidgetPosition) => ReactNode;
+ /** Rendered first, above every other widget. */
+ leading?: ReactNode;
/** Rendered last, below the footer links. */
trailing?: ReactNode;
/** Drops the internal sidebar ad — for templates carrying their own. */
@@ -103,6 +106,7 @@ export function PostWidgets({
hideSignupWidget = false,
hideToc = false,
getRailAd,
+ leading,
trailing,
hideAdWidget,
}: PostWidgetsProps): ReactElement {
@@ -163,6 +167,8 @@ export function PostWidgets({
return (
+ {leading}
+
{!hideSignupWidget && }
{withAd(PostWidgetPosition.Source, sourceCard)}
{withAd(
diff --git a/packages/shared/src/components/post/common.tsx b/packages/shared/src/components/post/common.tsx
index 9b9bb526448..6306f86ab30 100644
--- a/packages/shared/src/components/post/common.tsx
+++ b/packages/shared/src/components/post/common.tsx
@@ -97,6 +97,8 @@ export interface PostContentProps
* extensions.
*/
getWidgetRailAd?: (position: PostWidgetPosition) => ReactNode;
+ /** Rendered at the very top of the widget column. */
+ widgetsLeading?: ReactNode;
/**
* Replaces the default TLDR paragraph so an ad template can interleave
* units between summary segments. Like every ad prop here: only the
diff --git a/packages/shared/src/components/post/preferredSources/PreferGoogleButton.tsx b/packages/shared/src/components/post/preferredSources/PreferGoogleButton.tsx
new file mode 100644
index 00000000000..bba393cb274
--- /dev/null
+++ b/packages/shared/src/components/post/preferredSources/PreferGoogleButton.tsx
@@ -0,0 +1,66 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import type { ButtonProps } from '../../buttons/Button';
+import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
+import { GoogleIcon } from '../../icons';
+import {
+ getPreferredSourceUrl,
+ DAILY_DEV_DOMAIN,
+} from '../../../lib/preferredSources';
+
+export type PreferGoogleButtonProps = Pick<
+ ButtonProps<'button'>,
+ 'size' | 'variant'
+> & {
+ label?: string;
+ className?: string;
+ /**
+ * Falls back to the deeplink instead of Google's script. Required anywhere
+ * the script cannot run or has not initialised.
+ */
+ useDeeplink?: boolean;
+ isReady?: boolean;
+ onAdd?: () => void;
+};
+
+/**
+ * Adds daily.dev to the reader's Google preferred sources.
+ *
+ * Google's own button renders in an iframe we cannot theme, so this drives the
+ * documented JS API from our own `Button` — same outcome, our design system.
+ * Google sets no wording rule for a custom badge; the binding constraint is the
+ * G mark itself, which must stay full-colour and unmodified.
+ */
+export function PreferGoogleButton({
+ label = 'Add as preferred source',
+ size = ButtonSize.Small,
+ variant = ButtonVariant.Primary,
+ className,
+ useDeeplink = false,
+ isReady = true,
+ onAdd,
+}: PreferGoogleButtonProps): ReactElement {
+ const linkProps = useDeeplink
+ ? ({
+ tag: 'a',
+ href: getPreferredSourceUrl(DAILY_DEV_DOMAIN),
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ } as const)
+ : {};
+
+ return (
+ }
+ onClick={onAdd}
+ size={size}
+ variant={variant}
+ >
+ {label}
+
+ );
+}
diff --git a/packages/shared/src/components/post/preferredSources/PreferGoogleSourceAction.tsx b/packages/shared/src/components/post/preferredSources/PreferGoogleSourceAction.tsx
new file mode 100644
index 00000000000..d3d8611c2ee
--- /dev/null
+++ b/packages/shared/src/components/post/preferredSources/PreferGoogleSourceAction.tsx
@@ -0,0 +1,42 @@
+import type { ReactElement } from 'react';
+import React, { useEffect } from 'react';
+import { ButtonSize, ButtonVariant } from '../../buttons/Button';
+import { usePreferredSource } from '../../../hooks/usePreferredSource';
+import { PreferGoogleButton } from './PreferGoogleButton';
+
+/**
+ * The widget-column ask, at the very top of the rail — above the source card,
+ * the signup widget and the ad slot. No margin of its own: PageWidgets already
+ * sets the gap between rail items, and an extra one here would push the whole
+ * column down.
+ */
+export function PreferGoogleSourceAction(): ReactElement | null {
+ const { isEligible, isReady, useDeeplink, onAdd, onImpression } =
+ usePreferredSource({
+ placement: 'post widgets',
+ });
+
+ useEffect(() => {
+ if (isEligible) {
+ onImpression();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- once per appearance
+ }, [isEligible]);
+
+ if (!isEligible) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/shared/src/components/post/preferredSources/PreferredSearchCard.tsx b/packages/shared/src/components/post/preferredSources/PreferredSearchCard.tsx
new file mode 100644
index 00000000000..18d07c96760
--- /dev/null
+++ b/packages/shared/src/components/post/preferredSources/PreferredSearchCard.tsx
@@ -0,0 +1,146 @@
+import type { ReactElement } from 'react';
+import React, { useEffect } from 'react';
+import classNames from 'classnames';
+import { Card, CardTitle } from '../../cards/common/Card';
+import { Header } from '../../marketing/cta/common';
+import { ButtonSize, ButtonVariant } from '../../buttons/Button';
+import { GoogleIcon } from '../../icons';
+import LogoIcon from '../../../svg/LogoIcon';
+import { usePreferredSource } from '../../../hooks/usePreferredSource';
+import { PreferGoogleButton } from './PreferGoogleButton';
+
+/**
+ * The daily.dev favicon, rebuilt in CSS: the shipped icon is a PNG in each
+ * app's `public/`, which a shared component cannot reach, and this preview
+ * needs the real one — Google puts a site's actual favicon beside its result,
+ * so the bare white logo mark read as neither Google nor daily.dev.
+ *
+ * The two colours are sampled from `favicon-32x32.png` and are deliberately
+ * literals: they mirror an image asset, not a theme token, so they must not
+ * follow the app's light/dark switch.
+ */
+const Favicon = (): ReactElement => (
+
+
+
+);
+
+/**
+ * A glimpse of a Google results page with daily.dev marked Preferred. Drawn in
+ * CSS rather than shipped as an asset: it stays crisp at any density and costs
+ * the feed no image request. The G mark is Google's own, unmodified.
+ *
+ * Deliberately light in both themes. This is a picture *of Google*, not a piece
+ * of our UI — themed dark it stopped reading as a search result at all, which
+ * is the one job it has. Hence the literal colours: Google's own result palette
+ * rather than our tokens, which would flip with the app.
+ */
+export const SearchPreview = ({
+ className,
+ query = 'cursor agent mode review',
+ title = 'Cursor agent mode: three weeks in production',
+}: {
+ className?: string;
+ query?: string;
+ title?: string;
+}): ReactElement => (
+
+
+
+
+ {query}
+
+
+
+
+
+
+ daily.dev
+
+
+ Preferred
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+);
+
+/**
+ * Fills a feed ad position the ad server could not fill. It never takes a slot
+ * from real content — only one that would otherwise render a grey placeholder.
+ */
+export function PreferredSearchCard({
+ fallback = null,
+}: {
+ /** Rendered instead when the prompt is not eligible — e.g. the placeholder. */
+ fallback?: ReactElement | null;
+} = {}): ReactElement | null {
+ const { isEligible, isReady, useDeeplink, onAdd, onDismiss, onImpression } =
+ usePreferredSource({ placement: 'feed ad fallback' });
+
+ useEffect(() => {
+ if (isEligible) {
+ onImpression();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- once per appearance
+ }, [isEligible]);
+
+ if (!isEligible) {
+ return fallback;
+ }
+
+ return (
+
+
+
+ See daily.dev in your Google results
+
+
+
+
+ );
+}
diff --git a/packages/shared/src/components/post/preferredSources/PreferredSourceSetting.tsx b/packages/shared/src/components/post/preferredSources/PreferredSourceSetting.tsx
new file mode 100644
index 00000000000..92908032fed
--- /dev/null
+++ b/packages/shared/src/components/post/preferredSources/PreferredSourceSetting.tsx
@@ -0,0 +1,51 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../../typography/Typography';
+import { ButtonSize, ButtonVariant } from '../../buttons/Button';
+import { useGooglePreferredSource } from '../../../hooks/useGooglePreferredSource';
+import { PreferGoogleButton } from './PreferGoogleButton';
+
+/**
+ * The permanent home for the ask.
+ *
+ * Every other surface goes quiet after one click or one dismissal, which would
+ * otherwise leave a reader who changed their mind with no way back. This row
+ * ignores that state and is always available.
+ *
+ * A button, not a toggle: Google exposes no way to read whether the reader
+ * already added us, and a switch would promise a state we cannot show.
+ */
+export function PreferredSourceSetting(): ReactElement {
+ const { isReady, hasFailed, addPreferredSource } = useGooglePreferredSource();
+
+ return (
+
+
+
+ Preferred source on Google
+
+
+ Show daily.dev more often in Top Stories and AI Overviews.
+
+
+
+
+ );
+}
diff --git a/packages/shared/src/components/post/preferredSources/index.ts b/packages/shared/src/components/post/preferredSources/index.ts
new file mode 100644
index 00000000000..5e27fb18ad0
--- /dev/null
+++ b/packages/shared/src/components/post/preferredSources/index.ts
@@ -0,0 +1,4 @@
+export * from './PreferGoogleButton';
+export * from './PreferGoogleSourceAction';
+export * from './PreferredSearchCard';
+export * from './PreferredSourceSetting';
diff --git a/packages/shared/src/hooks/useGooglePreferredSource.ts b/packages/shared/src/hooks/useGooglePreferredSource.ts
new file mode 100644
index 00000000000..dc46f9cc0b6
--- /dev/null
+++ b/packages/shared/src/hooks/useGooglePreferredSource.ts
@@ -0,0 +1,124 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { ThemeMode, useSettingsContext } from '../contexts/SettingsContext';
+import {
+ PREFERRED_SOURCE_SCRIPT_ID,
+ PREFERRED_SOURCE_SRC,
+ PREFERRED_SOURCE_TIMEOUT_MS,
+} from '../lib/preferredSources';
+
+type PreferredSourceTheme = 'light' | 'dark';
+
+type PreferredSourceApi = {
+ init: (options: { theme?: PreferredSourceTheme; lang?: string }) => void;
+ addPreferredSource: () => void;
+};
+
+declare global {
+ // eslint-disable-next-line no-var, vars-on-top
+ var PREFERRED_SOURCE: Array<(api: PreferredSourceApi) => void> | undefined;
+}
+
+const resolveTheme = (mode: ThemeMode): PreferredSourceTheme => {
+ if (mode === ThemeMode.Auto) {
+ return globalThis.matchMedia?.('(prefers-color-scheme: light)').matches
+ ? 'light'
+ : 'dark';
+ }
+
+ return mode === ThemeMode.Light ? 'light' : 'dark';
+};
+
+/**
+ * Loads Google's publisher script in `manual` mode and hands back a trigger.
+ *
+ * Manual mode is not optional for us. The documented drop-in — an empty
+ * `
` — is scanned once, when the script
+ * loads, and every post page in the webapp is reached by a client-side route
+ * change long after that. The auto-scan would find nothing. Manual mode also
+ * lets us keep our own `Button` instead of the iframe Google renders, which
+ * cannot inherit our tokens and adds a per-embed layout shift.
+ *
+ * The script is fetched on demand rather than from ``, so a post page that
+ * never shows the widget never pays for it.
+ */
+export const useGooglePreferredSource = ({
+ enabled = true,
+ lang,
+}: {
+ enabled?: boolean;
+ lang?: string;
+} = {}): {
+ isReady: boolean;
+ hasFailed: boolean;
+ addPreferredSource: () => void;
+} => {
+ const { themeMode } = useSettingsContext();
+ const [isReady, setIsReady] = useState(false);
+ const [hasFailed, setHasFailed] = useState(false);
+ const apiRef = useRef();
+ // Read at callback time, not closed over: the effect must not re-run when the
+ // reader flips the theme, because every run pushes another callback onto a
+ // queue with no way to remove one.
+ const themeRef = useRef(themeMode);
+ themeRef.current = themeMode;
+
+ useEffect(() => {
+ if (!enabled || typeof window === 'undefined') {
+ return undefined;
+ }
+
+ let cancelled = false;
+ let timeout: ReturnType;
+
+ const onApi = (api: PreferredSourceApi) => {
+ if (cancelled) {
+ return;
+ }
+
+ // Cancel the failure timer first: it fires on a wall clock, so without
+ // this a script that simply loaded slowly would still be reported as
+ // blocked and every surface would drop to the deeplink.
+ clearTimeout(timeout);
+ apiRef.current = api;
+ api.init({ theme: resolveTheme(themeRef.current), lang });
+ setIsReady(true);
+ setHasFailed(false);
+ };
+
+ globalThis.PREFERRED_SOURCE = globalThis.PREFERRED_SOURCE || [];
+ globalThis.PREFERRED_SOURCE.push(onApi);
+
+ // `news.google.com/swg/...` is exactly the shape of host a content blocker
+ // eats, and a blocked script fires no error in every browser — so the
+ // timeout is the real detector and `onerror` only makes it faster. Without
+ // one of them the button stays disabled forever, which is worse than not
+ // offering it: callers switch to the deeplink, which needs no script.
+ const fail = () => {
+ if (!cancelled && !apiRef.current) {
+ setHasFailed(true);
+ }
+ };
+ timeout = setTimeout(fail, PREFERRED_SOURCE_TIMEOUT_MS);
+
+ if (!document.getElementById(PREFERRED_SOURCE_SCRIPT_ID)) {
+ const script = document.createElement('script');
+ script.id = PREFERRED_SOURCE_SCRIPT_ID;
+ script.src = PREFERRED_SOURCE_SRC;
+ script.async = true;
+ script.setAttribute('preferred-sources-control', 'manual');
+ script.addEventListener('error', fail);
+ document.head.appendChild(script);
+ }
+
+ return () => {
+ cancelled = true;
+ clearTimeout(timeout);
+ };
+ }, [enabled, lang]);
+
+ const addPreferredSource = useCallback(() => {
+ apiRef.current?.addPreferredSource();
+ }, []);
+
+ return { isReady, hasFailed, addPreferredSource };
+};
diff --git a/packages/shared/src/hooks/usePreferredSource.ts b/packages/shared/src/hooks/usePreferredSource.ts
new file mode 100644
index 00000000000..e478bcfa5d6
--- /dev/null
+++ b/packages/shared/src/hooks/usePreferredSource.ts
@@ -0,0 +1,139 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
+import { useConditionalFeature } from './useConditionalFeature';
+import usePersistentContext from './usePersistentContext';
+import { useAuthContext } from '../contexts/AuthContext';
+import { useLogContext } from '../contexts/LogContext';
+import { featurePreferredSource } from '../lib/featureManagement';
+import { LogEvent, TargetType } from '../lib/log';
+import type { PreferredSourceState } from '../lib/preferredSources';
+import {
+ PREFERRED_SOURCE_FORCE_KEY,
+ PREFERRED_SOURCE_STATE_KEY,
+} from '../lib/preferredSources';
+import { useGooglePreferredSource } from './useGooglePreferredSource';
+
+export type UsePreferredSourceProps = {
+ /** Which surface is asking. Goes out with every event. */
+ placement: string;
+ /** Extra gate on top of the flag and the global state. */
+ shouldEvaluate?: boolean;
+};
+
+export type UsePreferredSource = {
+ /** The flag is on and the reader has not answered yet. */
+ isEligible: boolean;
+ isReady: boolean;
+ /**
+ * Google's script never arrived. Render the deeplink instead — it needs no
+ * script, so the ask still works for a reader running a content blocker.
+ */
+ useDeeplink: boolean;
+ /** Opens Google's flow, logs the click and silences every other surface. */
+ onAdd: () => void;
+ /** Silences every surface without opening Google. */
+ onDismiss: () => void;
+ onImpression: () => void;
+};
+
+/**
+ * The one gate every Preferred Sources surface goes through.
+ *
+ * Because Google has no read API, "already added" is our own optimistic state:
+ * a reader who clicks is treated as done even if they abandon Google's dialog.
+ * That is the right trade — asking again is worse than counting one non-answer
+ * as a yes — and it is why the settings row exists as a permanent way back in.
+ */
+export const usePreferredSource = ({
+ placement,
+ shouldEvaluate = true,
+}: UsePreferredSourceProps): UsePreferredSource => {
+ const router = useRouter();
+ // REVIEW AFFORDANCE — remove before merge. The flag is off by default, so a
+ // Vercel preview would show nothing; `?preferredSource=1` forces the gate on
+ // so the placements can be reviewed without a GrowthBook rule.
+ //
+ // Sticky for the tab, deliberately: the param survives a full page load but
+ // not client-side navigation, so opening a post from the feed (which is a
+ // modal over the feed, with no query string of its own) would silently drop
+ // it and the reviewer would see nothing. sessionStorage carries it across
+ // every route until the tab closes.
+ //
+ // Read in an effect rather than during render: the server has no
+ // sessionStorage, so reading it inline would make the first client render
+ // disagree with the server HTML and trip a hydration error.
+ const [isForced, setIsForced] = useState(false);
+ const isForcedParam = router?.query?.preferredSource === '1';
+
+ useEffect(() => {
+ if (isForcedParam) {
+ globalThis.sessionStorage?.setItem(PREFERRED_SOURCE_FORCE_KEY, '1');
+ setIsForced(true);
+ return;
+ }
+
+ setIsForced(
+ globalThis.sessionStorage?.getItem(PREFERRED_SOURCE_FORCE_KEY) === '1',
+ );
+ }, [isForcedParam]);
+ const { isAuthReady } = useAuthContext();
+ const { logEvent } = useLogContext();
+ const [state, setState, isStateLoaded] =
+ usePersistentContext(
+ PREFERRED_SOURCE_STATE_KEY,
+ null,
+ );
+
+ // Deliberately not gated on being signed in. Post pages and the feed are
+ // public, and a reader who arrived from Google — the one person for whom
+ // this ask is self-interested rather than a favour — is usually signed out.
+ // Capping is local-storage based, so it works for them too.
+ const gate = isAuthReady && shouldEvaluate;
+ const { value: isEnabled } = useConditionalFeature({
+ feature: featurePreferredSource,
+ shouldEvaluate: gate,
+ });
+
+ const isEligible =
+ gate && (!!isEnabled || isForced) && isStateLoaded && !state;
+
+ const { isReady, hasFailed, addPreferredSource } = useGooglePreferredSource({
+ enabled: isEligible,
+ });
+
+ const onImpression = useCallback(() => {
+ logEvent({
+ event_name: LogEvent.ImpressionPreferredSource,
+ target_type: TargetType.PreferredSource,
+ target_id: placement,
+ });
+ }, [logEvent, placement]);
+
+ const onAdd = useCallback(() => {
+ logEvent({
+ event_name: LogEvent.ClickPreferredSource,
+ target_type: TargetType.PreferredSource,
+ target_id: placement,
+ });
+ addPreferredSource();
+ setState('added');
+ }, [addPreferredSource, logEvent, placement, setState]);
+
+ const onDismiss = useCallback(() => {
+ logEvent({
+ event_name: LogEvent.DismissPreferredSource,
+ target_type: TargetType.PreferredSource,
+ target_id: placement,
+ });
+ setState('dismissed');
+ }, [logEvent, placement, setState]);
+
+ return {
+ isEligible,
+ isReady,
+ useDeeplink: hasFailed,
+ onAdd,
+ onDismiss,
+ onImpression,
+ };
+};
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 090bda416d2..ad763391119 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -354,3 +354,8 @@ export const featureSidebarTourExistingBefore = new Feature(
'sidebar_tour_existing_before',
'',
);
+
+// Google Preferred Sources. One flag for every surface: the ask is the same
+// ask everywhere, and the capping is global, so splitting it per placement
+// would let a reader meet it twice after silencing it once.
+export const featurePreferredSource = new Feature('preferred_source', false);
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 1ef0830f678..6d32b6b9c6f 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -115,6 +115,12 @@ export enum LogEvent {
EmptyAdsenseSlot = 'empty adsense slot',
AdsenseSlotError = 'adsense slot error',
AdsenseTestMode = 'adsense test mode',
+ // Google Preferred Sources. Google reports nothing back — no read API, and
+ // no Search Console dimension — so these two events are the only measurement
+ // this feature will ever have.
+ ImpressionPreferredSource = 'impression preferred source',
+ ClickPreferredSource = 'click preferred source',
+ DismissPreferredSource = 'dismiss preferred source',
OpenSmartComposer = 'open smart composer',
CloseSmartComposer = 'close smart composer',
SubmitSmartComposer = 'submit smart composer',
@@ -555,6 +561,7 @@ export enum TargetType {
PromotionalBanner = 'promotion_banner',
MarketingCtaPopover = 'promotion_popover',
MarketingCtaPopoverSmall = 'promotion_popover_small',
+ PreferredSource = 'preferred source',
MarketingCtaPlus = 'promotion_plus',
MarketingCtaBrief = 'promotion_briefing',
MarketingCtaHelpGuide = 'promotion_help_guide',
diff --git a/packages/shared/src/lib/preferredSources.ts b/packages/shared/src/lib/preferredSources.ts
new file mode 100644
index 00000000000..8072ad264e0
--- /dev/null
+++ b/packages/shared/src/lib/preferredSources.ts
@@ -0,0 +1,86 @@
+/**
+ * Google Preferred Sources — https://developers.google.com/search/docs/appearance/preferred-sources
+ *
+ * A reader can mark a site as "preferred" and Google then surfaces it more often
+ * in Top Stories, AI Overviews and AI Mode. Two integration routes exist, and the
+ * difference between them decides everything about where we can put this:
+ *
+ * - The official JS button adds *the domain that hosts the button*. On daily.dev
+ * that is always daily.dev, never the publisher whose article the reader is on.
+ * - The deeplink takes a `q` param, so it can target *any* domain — including the
+ * source of the post being read.
+ *
+ * Only domain and subdomain level sites are eligible; a path like example.com/blog
+ * is not, which is why `normalizePreferredSourceDomain` rejects anything with one.
+ */
+
+export const PREFERRED_SOURCE_SCRIPT_ID = 'google-preferred-source';
+export const PREFERRED_SOURCE_SRC =
+ 'https://news.google.com/swg/js/v1/publisher.js';
+
+/** How long to wait for Google's script before falling back to the deeplink. */
+export const PREFERRED_SOURCE_TIMEOUT_MS = 4000;
+export const PREFERRED_SOURCE_DEEPLINK =
+ 'https://www.google.com/preferences/source';
+
+export const DAILY_DEV_DOMAIN = 'daily.dev';
+
+/**
+ * Reduces whatever the API handed us — a bare host, a full URL, a host with
+ * `www.` — to the host Google expects, or null when the value can never be
+ * eligible (empty, path-bearing, or not a hostname at all).
+ */
+export const normalizePreferredSourceDomain = (
+ value?: string,
+): string | null => {
+ if (!value) {
+ return null;
+ }
+
+ const trimmed = value.trim().toLowerCase();
+
+ if (!trimmed) {
+ return null;
+ }
+
+ let host = trimmed;
+
+ if (host.includes('://')) {
+ try {
+ host = new URL(host).hostname;
+ } catch {
+ return null;
+ }
+ } else if (host.includes('/')) {
+ // A bare host with a path is a subdirectory, which Google does not accept.
+ return null;
+ }
+
+ host = host.replace(/^www\./, '');
+
+ // Hostname, not an IP or a single label: at least one dot, no spaces, and a
+ // TLD of two or more letters.
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/.test(host)) {
+ return null;
+ }
+
+ return host;
+};
+
+export const getPreferredSourceUrl = (domain: string): string =>
+ `${PREFERRED_SOURCE_DEEPLINK}?q=${encodeURIComponent(domain)}`;
+
+/**
+ * One key for every surface. Google exposes no way to read whether a reader
+ * already added us, so this is the only "done" signal we will ever have: it is
+ * written optimistically on click, and it silences every prompt at once.
+ */
+export const PREFERRED_SOURCE_STATE_KEY = 'preferred_source_state';
+
+/** REVIEW AFFORDANCE — remove before merge. See usePreferredSource. */
+export const PREFERRED_SOURCE_FORCE_KEY = 'preferred_source_force';
+
+export type PreferredSourceState = 'added' | 'dismissed';
+
+/** Identifies our own notification / quest so the frontend can decorate it. */
+export const PREFERRED_SOURCE_REFERENCE_ID = 'google_preferred_source';
diff --git a/packages/storybook/public/preferred-source-badge.png b/packages/storybook/public/preferred-source-badge.png
new file mode 100644
index 00000000000..45394d5e663
Binary files /dev/null and b/packages/storybook/public/preferred-source-badge.png differ
diff --git a/packages/storybook/public/preferred-source-cover.png b/packages/storybook/public/preferred-source-cover.png
new file mode 100644
index 00000000000..92516f020dc
Binary files /dev/null and b/packages/storybook/public/preferred-source-cover.png differ
diff --git a/packages/storybook/stories/preferred-sources/Measurement.stories.tsx b/packages/storybook/stories/preferred-sources/Measurement.stories.tsx
new file mode 100644
index 00000000000..e9ff653c103
--- /dev/null
+++ b/packages/storybook/stories/preferred-sources/Measurement.stories.tsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import {
+ Bullets,
+ CodeBlock,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+ SpecTable,
+} from '../open-graph/ogStoryLayout';
+
+const Measurement = (): React.ReactElement => (
+
+
+ Worth being blunt before anyone builds a business case on this: Google
+ publishes almost nothing that a publisher can act on, and the one headline
+ number it does publish is unfalsifiable as stated.
+
+
+ The public numbers, and what each is worth
+
+
+ So the honest framing for whichever option we ship: this is a goodwill and
+ positioning bet with a plausible traffic upside, not a measurable traffic
+ channel. If we present it internally as the latter, we will be asked for
+ numbers that do not exist.
+
+
+
+
+ What we can measure ourselves
+
+ Our own click is the only number anyone will have, which makes
+ instrumenting it non-optional. Both components take an{' '}
+ onClick/onAdd callback for exactly this.
+
+ {`// LogEvent additions
+ClickPreferredSource = 'click preferred source',
+ImpressionPreferredSource = 'impression preferred source',
+
+// extra: { target: 'dailydev' | 'source', placement, copy, domain? }
+// placement: 'post_page' | 'new_tab' | 'sidebar' | 'settings' | 'footer' | ...`}
+
+
+
+
+ Three ways this goes wrong
+
+
+ The one that is not a UI change
+
+ Google has now publicly endorsed the idea daily.dev has been arguing since
+ day one: readers should choose their sources, rather than have an
+ algorithm choose for them. Google shipped a preferences panel buried in
+ search settings; we shipped a whole product. That comparison writes
+ itself, and it is the cheapest and highest-ceiling thing on this page — an
+ email, a blog post, and a campaign, with no engineering dependency at all.
+
+
+);
+
+const meta: Meta = {
+ title: 'Preferred Sources/5. Measurement & Risks',
+ component: Measurement,
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+export const Default: StoryObj = { name: 'Measurement' };
diff --git a/packages/storybook/stories/preferred-sources/Mechanism.stories.tsx b/packages/storybook/stories/preferred-sources/Mechanism.stories.tsx
new file mode 100644
index 00000000000..acc76a99128
--- /dev/null
+++ b/packages/storybook/stories/preferred-sources/Mechanism.stories.tsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import {
+ Bullets,
+ CodeBlock,
+ Divider,
+ Heading,
+ Muted,
+ Page,
+ PageHeader,
+ SpecTable,
+} from '../open-graph/ogStoryLayout';
+
+const Mechanism = (): React.ReactElement => (
+
+
+ Everything below is from Google’s Search Central documentation for
+ publishers, checked against how our webapp is actually put together. The
+ gotchas are the reason we cannot use the copy-paste snippet Google leads
+ with.
+
+
+
+ The official button — adds the hosting domain
+
+
+ Google’s headline integration is two lines. It renders a localised,
+ Google-styled button that adds the current site and returns the reader to
+ the page they were on.
+
+ {`
+
+
+
+`}
+
+ data-theme takes light (default) or{' '}
+ dark; data-lang overrides the auto-detected
+ language. There is no attribute for which domain to add — the
+ script reads the origin it is running on.
+
+
+ The deeplink — adds any domain
+
+ A plain URL into Google’s source preferences tool. No script, no iframe,
+ no third-party bytes, and the q parameter takes any eligible
+ host.
+
+ {`https://www.google.com/preferences/source?q=towardsdatascience.com`}
+
+ This is the only route that can express “prefer the publisher of this
+ post” from a daily.dev page. It costs a tab switch instead of an in-page
+ confirmation — the one real downside versus the script.
+
+
+
+
+ The four gotchas
+
+ script has run. An empty
mounted by React is never scanned, so nothing renders.',
+ 'Load with preferred-sources-control="manual" and drive it from the PREFERRED_SOURCE callback queue, calling addPreferredSource() from our own click handler.',
+ ],
+ [
+ 'The rendered button is an iframe — one per embed',
+ 'It cannot inherit our tokens, typography or radii, it will not match the buttons beside it, and it reserves no space until it loads, so it shifts the sidebar.',
+ 'Use the advanced JS API with our own
+
+ ),
+};
+
+export default meta;
+
+export const Current: StoryObj = { args: { variant: 'current' } };
+export const Proposed: StoryObj = { args: { variant: 'proposed' } };
diff --git a/packages/storybook/stories/preferred-sources/review/2-Achievement.stories.tsx b/packages/storybook/stories/preferred-sources/review/2-Achievement.stories.tsx
new file mode 100644
index 00000000000..fd3a7f1b9c6
--- /dev/null
+++ b/packages/storybook/stories/preferred-sources/review/2-Achievement.stories.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { AchievementCard } from '@dailydotdev/shared/src/features/profile/components/achievements/AchievementCard';
+import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements';
+import { AchievementType } from '@dailydotdev/shared/src/graphql/user/achievements';
+import { ReviewProviders } from './_providers';
+
+type Args = { state: 'locked' | 'unlocked' };
+
+/**
+ * The achievement route, using the real `AchievementCard`.
+ *
+ * Everything here is **backend-owned**: an `Achievement` row carries its own
+ * id, name, description, image, type, points and rarity, and the API decides
+ * when `unlockedAt` is stamped. The client renders whatever it is handed, so
+ * there is no app code to write for this one — only a definition to create and
+ * an image to host.
+ *
+ * The honest caveat, and the reason this is the weakest of the five: Google
+ * exposes no read API, so "did they actually add us" is unknowable. The unlock
+ * can only fire on our own click event, which a reader can trigger and then
+ * cancel Google's dialog. Points for an unverifiable action.
+ */
+const achievement = {
+ id: 'preferred-source-google',
+ name: 'Preferred',
+ description: 'Made daily.dev a preferred source on Google.',
+ // Local copy so the mock-up renders. A real definition points at the hosted
+ // asset — upload `packages/storybook/public/preferred-source-badge.png`.
+ image: '/preferred-source-badge.png',
+ type: AchievementType.Instant,
+ points: 100,
+ rarity: 12,
+ unit: null,
+};
+
+const userAchievement = (unlocked: boolean): UserAchievement => ({
+ achievement,
+ progress: unlocked ? 1 : 0,
+ unlockedAt: unlocked ? new Date('2026-09-07').toISOString() : null,
+ createdAt: new Date('2026-09-01').toISOString(),
+ updatedAt: new Date('2026-09-07').toISOString(),
+});
+
+const meta: Meta = {
+ title: 'Preferred Sources/Review/2. Achievement',
+ args: { state: 'unlocked' },
+ argTypes: {
+ state: { control: 'radio', options: ['locked', 'unlocked'] },
+ },
+ parameters: { layout: 'fullscreen' },
+ render: ({ state }) => (
+
+
+
+
+
+
+
+ ),
+};
+
+export default meta;
+
+export const Unlocked: StoryObj = { args: { state: 'unlocked' } };
+export const Locked: StoryObj = { args: { state: 'locked' } };
diff --git a/packages/storybook/stories/preferred-sources/review/3-FeedFallback.stories.tsx b/packages/storybook/stories/preferred-sources/review/3-FeedFallback.stories.tsx
new file mode 100644
index 00000000000..58088172949
--- /dev/null
+++ b/packages/storybook/stories/preferred-sources/review/3-FeedFallback.stories.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn } from 'storybook/test';
+import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid';
+import { PlaceholderGrid } from '@dailydotdev/shared/src/components/cards/placeholder/PlaceholderGrid';
+import { ReviewProviders, reviewPost } from './_providers';
+import { feedPosts, cardHandlers } from './_feed';
+import { PreferredSearchCard } from './_PreferredSearchCard';
+
+type Args = { variant: 'current' | 'proposed' };
+
+/**
+ * The feed's ad position when the ad server has nothing: today a grey
+ * placeholder. Agreed replacement: a card that shows the reader what they
+ * get — their Google results with daily.dev marked Preferred — and one
+ * primary button.
+ */
+const meta: Meta = {
+ title: 'Preferred Sources/Review/3. Feed: empty ad slot',
+ args: { variant: 'proposed' },
+ argTypes: {
+ variant: { control: 'radio', options: ['current', 'proposed'] },
+ },
+ parameters: { layout: 'fullscreen' },
+ render: ({ variant }) => (
+
+