---
title: "Dynamic Card Previews"
description: "Dub powers dynamic card previews and Open Graph image generation across its platform using Next.js ImageResponse, edge runtimes, and interactive UI components. These capabilities allow developers a..."
last_updated: "2026-10-12T07:00:10.151461+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-attribution/dynamic-card-previews"
---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [apps/web/app/api/og/partner-rewind/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-rewind/route.tsx)
- [apps/web/ui/partners/groups/design/previews/portal-preview.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/partners/groups/design/previews/portal-preview.tsx)
- [apps/web/app/api/og/avatar/...seed/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/avatar/%5B%5B...seed%5D%5D/route.tsx)
- [apps/web/ui/partners/groups/design/previews/embed-preview.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/partners/groups/design/previews/embed-preview.tsx)
- [apps/web/ui/links/link-builder/link-preview.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/links/link-builder/link-preview.tsx)
- [apps/web/app/api/og/program/categories/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/categories/route.tsx)
- [apps/web/app/api/og/program/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/route.tsx)
- [apps/web/app/api/og/partner-earnings/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-earnings/route.tsx)
- [apps/web/app/api/og/analytics/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx)
- [apps/web/app/api/qr/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/qr/route.tsx)
- [apps/web/ui/partners/bounties/bounty-social-content-preview.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/partners/bounties/bounty-social-content-preview.tsx)
- [apps/web/app/ee/api/partner-profile/programs/programId/bounties/bountyId/social-content-stats/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/bounties/%5BbountyId%5D/social-content-stats/route.ts)
- [apps/web/app/ee/api/embed/referrals/bounties/bountyId/social-content-stats/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/embed/referrals/bounties/%5BbountyId%5D/social-content-stats/route.ts)
- [apps/web/app/domain/key/proxy/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/%5Bkey%5D/proxy/page.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/rewind/2025/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/page.tsx)
- [apps/web/app/cloaked/url/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/cloaked/%5Burl%5D/page.tsx)
- [apps/web/app/api/links/metatags/utils.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/metatags/utils.ts)
- [apps/web/app/app.dub.co/(deeplink)/deeplink/domain/...key/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx)
- [apps/web/lib/storage.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/storage.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx)
- [apps/web/lib/api/scrape-creators/get-social-content.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/scrape-creators/get-social-content.ts)
- [packages/ui/src/link-preview.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/ui/src/link-preview.tsx)
- [apps/web/ui/modals/link-builder/og-modal.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/modals/link-builder/og-modal.tsx)
</details>

## Overview

Dub powers dynamic card previews and Open Graph image generation across its platform using Next.js `ImageResponse`, edge runtimes, and interactive UI components. These capabilities allow developers and partners to generate customized social media assets, analytics visualizers, affiliate program summaries, and real-time portal brand previews programmatically.

Sources: [apps/web/app/api/og/avatar/...seed/route.tsx:5-10](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/avatar/%5B%5B...seed%5D%5D/route.tsx#L5-L10), [apps/web/app/api/og/partner-earnings/route.tsx:5-10](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-earnings/route.tsx#L5-L10), [apps/web/ui/links/link-builder/link-preview.tsx:61-75](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/links/link-builder/link-preview.tsx#L61-L75)

## Avatar Image Generation via Edge

### Edge Route Configuration and Request Handling

The avatar generation endpoint operates on the Vercel Edge runtime via `apps/web/app/api/og/avatar/[[...seed]]/route.tsx`. It accepts incoming `GET` requests with a dynamic catch-all `seed` path parameter or a fallback query parameter. CORS headers are configured dynamically based on the request origin: if the `origin` header terminates with `.dub.co`, it is explicitly reflected back in the `Access-Control-Allow-Origin` response header alongside allowed methods and content-type headers.

```tsx
export const runtime = "edge";

export async function GET(
  req: NextRequest,
  props: { params: Promise<{ seed?: string[] }> },
) {
  const params = await props.params;
  const origin = req.headers.get("origin");
  // Validate the origin header and set CORS headers accordingly
  const corsHeaders = {
    "Access-Control-Allow-Methods": "GET",
    "Access-Control-Allow-Headers": "Content-Type",
  };

  if (origin && origin.endsWith(".dub.co")) {
    corsHeaders["Access-Control-Allow-Origin"] = origin;
  }

  const { searchParams } = new URL(req.url);
  const seed = params.seed?.[0] ?? searchParams.get("seed");
  const theme = getAvatarTheme(seed);
```
Sources: [apps/web/app/api/og/avatar/...seed/route.tsx:5-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/avatar/%5B%5B...seed%5D%5D/route.tsx#L5-L26)

> [!NOTE]
> The route uses a catch-all parameter `[[...seed]]` allowing seeds to be passed as clean URL path segments (e.g., `/api/og/avatar/john-doe`) while retaining fallback support for traditional query parameters (e.g., `/api/og/avatar?seed=john-doe`).

### Programmatic Rendering with Next.js ImageResponse

Using `@dub/utils`'s `getAvatarTheme(seed)`, the route retrieves a deterministic background and foreground color palette mapped from the input seed. It then renders a 128x128 pixel SVG-based layout using `ImageResponse` containing layered shapes that simulate a stylized head and shoulders with linear gradients and multiple inner shadow box-shadow definitions.

```tsx
return new ImageResponse(
  (
    <div
      tw="flex items-center justify-center w-full h-full relative"
      style={{
        background: theme.bg,
        display: "flex",
        margin: 0,
        padding: 0,
      }}
    >
      {/* Head */}
      <div
        tw="absolute w-[51px] h-[51px] rounded-full"
        style={{
          background: theme.fg,
          display: "flex",
          top: "28px",
          backgroundImage:
            "linear-gradient(135deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.2) 100%)",
          boxShadow:
            "inset 6px -5px 11px rgba(0,0,0,0.13), inset -18px -12px 19px rgba(255,255,255,0.4)",
        }}
      />
```
Sources: [apps/web/app/api/og/avatar/...seed/route.tsx:27-50](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/avatar/%5B%5B...seed%5D%5D/route.tsx#L27-L50)

The shoulders element extends the visual representation downward using a circular element clipped in half via `clipPath: "inset(0 0 50% 0)"` and styled with layered inner shadows for depth.

```tsx
{/* Shoulders */}
<div
  tw="absolute w-[102px] h-[102px] rounded-full"
  style={{
    background: theme.fg,
    display: "flex",
    top: "90px",
    clipPath: "inset(0 0 50% 0)",
    backgroundImage:
      "linear-gradient(135deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0.2) 100%)",
    boxShadow:
      "inset 10px -12px 19px rgba(0,0,0,0.4), inset -18px -12px 19px rgba(255,255,255,0.4), inset 2px -1px 11px rgba(0,0,0,0.1)",
  }}
/>
```
Sources: [apps/web/app/api/og/avatar/...seed/route.tsx:51-64](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/avatar/%5B%5B...seed%5D%5D/route.tsx#L51-L64)

### Response Caching and Storage Helpers

The generated image response sets explicit caching headers for edge delivery via Vercel CDN, forcing a one-year public immutable cache duration alongside the CORS headers.

```tsx
    </div>
  ),
  {
    width: 128,
    height: 128,
    headers: {
      ...corsHeaders,
      "Vercel-CDN-Cache-Control": "s-maxage=31536000",
      "Cache-Control": "public, max-age=31536000, immutable",
    },
  },
);
```
Sources: [apps/web/app/api/og/avatar/...seed/route.tsx:65-76](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/avatar/%5B%5B...seed%5D%5D/route.tsx#L65-L76)

Complementing the edge route, `apps/web/lib/storage.ts` provides storage utilities including `isStored(url)`, which verifies whether an image URL originates from Cloudflare R2 (`R2_URL`) or the avatar endpoint (`OG_AVATAR_URL`).

```typescript
export const isStored = (url: string) => {
  return url.startsWith(R2_URL) || url.startsWith(OG_AVATAR_URL);
};
```
Sources: [apps/web/lib/storage.ts:414-416](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/storage.ts#L414-L416)

## Partner Rewind Social Cards

### Server-Rendered Annual Performance Summary Endpoints

The partner rewind social card generation endpoint (`apps/web/app/api/og/partner-rewind/route.tsx`) dynamically renders annual performance summaries for affiliate partners. It validates incoming search parameters against the allowed `REWIND_STEPS` identifiers using Zod, authenticates the partner profile via `withPartnerProfile`, and asynchronously loads the Inter font (weight 700) alongside partner rewind metrics.

```tsx
export const GET = withPartnerProfile(async ({ partner, searchParams }) => {
  const { step: stepRaw } = z
    .object({
      step: z.enum(
        REWIND_STEPS.map((step) => step.id) as [string, ...string[]],
      ),
    })
    .parse(searchParams);

  const step = REWIND_STEPS.find((step) => step.id === stepRaw)!;

  const [interBold, rewind] = await Promise.all([
    loadGoogleFont("Inter:wght@700"),
    getPartnerRewind({ partnerId: partner.id }),
  ]);

  if (!rewind) {
    throw new DubApiError({
      code: "not_found",
      message: "Partner rewind not found",
    });
  }
```
Sources: [apps/web/app/api/og/partner-rewind/route.tsx:17-38](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-rewind/route.tsx#L17-L38)

> [!WARNING]
> If the requested partner rewind dataset does not exist, the route throws a `DubApiError` with the `not_found` error code rather than returning a default image.

### Metric Aggregation and Scaling Constants

Card dimensions are fixed at 1084 by 994 pixels (`WIDTH` and `HEIGHT`). Metric values are transformed based on whether the step's `valueType` is currency (dividing by 100 to convert cents) or standard count. Percentile rankings are evaluated against `REWIND_PERCENTILES` to assign medallion labels indicating top-tier standing among all partners.

```tsx
const percentileLabel = REWIND_PERCENTILES.find(
  ({ minPercentile }) => rewind[step.percentileId] >= minPercentile,
)?.label;

const value =
  step.valueType === "currency"
    ? Math.floor(rewind[step.id] / 100)
    : Math.floor(rewind[step.id]);
```
Sources: [apps/web/app/api/og/partner-rewind/route.tsx:39-48](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-rewind/route.tsx#L39-L48)

The card layout incorporates an SVG background grid pattern and radial gradients (`gradient1` using `#855AFC` and `gradient2` using `#FD3A4E`) wrapped inside an `ImageResponse` instance scaled by 1.5.

```tsx
return new ImageResponse(
  (
    <div
      tw="flex flex-col bg-neutral-50 w-full h-full items-center justify-between"
      style={{ fontFamily: "Inter" }}
    >
      {/* @ts-ignore */}
      <svg tw="absolute inset-0 text-black/10" width={WIDTH} height={HEIGHT}>
        <defs>
          <pattern
            id="grid"
            width={84}
            height={84}
            x={-46}
            y={-4}
            patternUnits="userSpaceOnUse"
          >
            <path
              d={`M 84 0 L 0 0 0 84`}
              fill="transparent"
              stroke="currentColor"
              strokeWidth={1}
            />
          </pattern>
          <radialGradient id="gradient1" cx="1.1" cy="0.1" r="0.5">
            <stop offset="0%" stopColor="#855AFC" stopOpacity={0.1} />
            <stop offset="100%" stopColor="#855AFC" stopOpacity={0} />
          </radialGradient>
          <radialGradient id="gradient2" cx="-0.1" cy="0.7" r="0.5">
            <stop offset="0%" stopColor="#FD3A4E" stopOpacity={0.05} />
            <stop offset="100%" stopColor="#FD3A4E" stopOpacity={0} />
          </radialGradient>
        </defs>
        <rect fill="url(#grid)" width={WIDTH} height={HEIGHT} />
        <rect fill="url(#gradient1)" width={WIDTH} height={HEIGHT} />
        <rect fill="url(#gradient2)" width={WIDTH} height={HEIGHT} />
      </svg>

      <div
        tw="relative bg-white border-neutral-200 flex w-full max-w-screen-sm flex-col rounded-2xl border p-10 shadow-sm mt-16"
        style={{ transform: "scale(1.5)", transformOrigin: "top center" }}
      >
        <div tw="flex grow flex-col">
          <span tw="text-neutral-900 text-lg font-semibold">
            {step.label}
          </span>

          <div tw="flex pt-2">
            <span tw="text-neutral-900 text-8xl font-bold">
              {step.valueType === "currency" && "$"}
              {nFormatter(value, { full: value <= 9999999 })}
            </span>
          </div>

          <div
            tw={cn("mt-5 flex items-center", !percentileLabel && "opacity-0")}
          >
            <img
              src="https://assets.dub.co/misc/partner-rewind-2025/top-medallion.jpg"
              tw="w-6 h-6 mr-2.5"
            />
            <span tw="text-neutral-900 text-base font-semibold">
              {percentileLabel} of all partners
            </span>
          </div>
        </div>

        <div tw="flex items-end justify-between">
          <span
            tw="text-neutral-900 max-w-[180px] text-3xl leading-8 font-bold"
            style={{ fontFamily: "Inter" }}
          >
            Dub Partner Rewind &rsquo;25
          </span>

          <img
            tw="-mb-4 -mr-1 -mt-8 flex h-[280px]"
            src={`${REWIND_ASSETS_PATH}/${step.image}`}
          />
        </div>
```
Sources: [apps/web/app/api/og/partner-rewind/route.tsx:49-148](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-rewind/route.tsx#L49-L148)

### Partner Dashboard Integration and Asset Preloading

The partner dashboard page (`apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/page.tsx`) authenticates the current session, queries the associated partner user record via Prisma, and preloads medallion imagery and step video assets for fluid playback.

```tsx
export default async function PartnerRewind2025Page() {
  const { user } = await getSession();

  if (!user.defaultPartnerId) redirect("/");

  const partnerUser = await prisma.partnerUser.findUnique({
    select: { partnerId: true },
    where: {
      userId_partnerId: {
        userId: user.id,
        partnerId: user.defaultPartnerId,
      },
    },
  });

  if (!partnerUser) redirect("/");

  const partnerRewind = await getPartnerRewind({
    partnerId: partnerUser.partnerId,
  });

  if (!partnerRewind) redirect("/");

  preload(`${REWIND_ASSETS_PATH}/top-medallion.png`, { as: "image" });
  REWIND_STEPS.forEach((step) => {
    preload(`${REWIND_ASSETS_PATH}/${step.video}`, { as: "video" });
  });
```
Sources: [apps/web/app/ee/partners.dub.co/dashboard/rewind/2025/page.tsx:15-42](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/page.tsx#L15-L42)

The dashboard layout renders a radial background grid accompanied by a multi-stop radial gradient helper component (`Gradient`) featuring brand colors such as `#72FE7D`, `#FD3A4E`, `#855AFC`, `#E4C795`, `#3A8BFD`, and `#EEA5BA`.

```tsx
  return (
    <PageContent
      title="Partner rewind"
      className="flex h-full flex-col"
      contentWrapperClassName="grow pt-0 lg:pt-0"
    >
      <div className="bg-bg-muted relative size-full">
        <div className="animate-fade-in absolute inset-0 overflow-hidden [mask-image:radial-gradient(transparent,black)]">
          <Grid
            cellSize={56}
            patternOffset={[-4, -28]}
            className="text-border-default/80"
          />
          <Gradient className="absolute bottom-0 left-0 h-[720px] w-96 -translate-x-1/2 translate-y-1/2 -rotate-[55deg] opacity-30" />
          <Gradient className="absolute right-0 top-0 h-[720px] w-96 -translate-y-1/2 translate-x-1/2 -rotate-[55deg] opacity-20" />
        </div>

        <div className="scrollbar-hide flex size-full items-center justify-center overflow-y-auto p-6">
          <PartnerRewind2025PageClient partnerRewind={partnerRewind} />
        </div>
      </div>
    </PageContent>
  );
}

function Gradient({ className }: { className?: string }) {
  return (
    <div
      className={cn(
        "[background-image:radial-gradient(140%_146%_at_93%_14%,#72FE7D,rgba(114,254,125,0)_50%),radial-gradient(126%_82%_at_56%_100%,#FD3A4E,rgba(253,58,78,0)_50%),radial-gradient(131%_124%_at_11%_35%,#855AFC,rgba(133,90,252,0)_50%),radial-gradient(117%_77%_at_100%_100%,#E4C795,rgba(228,199,149,0)_50%),radial-gradient(86%_74%_at_40%_59%,#3A8BFD,rgba(58,139,253,0)_50%),radial-gradient(115%_96%_at_42%_69%,#EEA5BA,rgba(238,165,186,0)_50%)]",
        "blur-[60px]",
        className,
      )}
    />
  );
}
```
Sources: [apps/web/app/ee/partners.dub.co/dashboard/rewind/2025/page.tsx:43-78](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/partners.dub.co/(dashboard)/rewind/2025/page.tsx#L43-L78)

## Partner Earnings Chart Cards

### Request Validation and Font Loading

The partner earnings Open Graph route (`apps/web/app/api/og/partner-earnings/route.tsx`) handles incoming GET requests authenticated through the `withPartnerProfile` wrapper. It parses URL search parameters via `getPartnerEarningsTimeseriesSchema`, extending the schema with a required `programId` string and an optional `background` enum defaulting to `"light"`.

```tsx
export const GET = withPartnerProfile(async ({ partner, searchParams }) => {
  const { programId, background, ...filters } =
    getPartnerEarningsTimeseriesSchema
      .extend({
        programId: z.string(),
        background: z.enum(["light", "dark"]).optional().default("light"),
      })
      .parse(searchParams);
```
Sources: [apps/web/app/api/og/partner-earnings/route.tsx:17-25](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-earnings/route.tsx#L17-L25)

Concurrently, the route invokes `loadGoogleFont` for `"Inter:wght@600"` and fetches the partner's earnings timeseries data using `getPartnerEarningsTimeseries` with `partnerId`, `programId`, and filters.

```tsx
const [interSemibold, timeseries] = await Promise.all([
  loadGoogleFont("Inter:wght@600"),
  getPartnerEarningsTimeseries({
    partnerId: partner.id,
    programId,
    filters,
  }),
]);
```
Sources: [apps/web/app/api/og/partner-earnings/route.tsx:26-33](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-earnings/route.tsx#L26-L33)

### Metric Calculation and Canvas Layout

Total earnings are computed by reducing the timeseries array. Start and end labels are formatted using `formatDate` with short month and numeric year options derived from the first and last data entries.

```tsx
const total = timeseries.reduce((acc, { earnings }) => acc + earnings, 0);

const startLabel =
  timeseries.length > 0
    ? formatDate(new Date(timeseries[0].start), {
        month: "short",
        year: "numeric",
      })
    : "";
const endLabel =
  timeseries.length > 0
    ? formatDate(new Date(timeseries[timeseries.length - 1].start), {
        month: "short",
        year: "numeric",
      })
    : "";
```
Sources: [apps/web/app/api/og/partner-earnings/route.tsx:35-50](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-earnings/route.tsx#L35-L50)

Card generation utilizes fixed dimensions of 1368 by 994 pixels (`WIDTH` and `HEIGHT`). Background assets are selected from `BACKGROUND_IMAGES` based on the light or dark theme setting, which also determines the SVG logo fill color (`#ffffff` for dark, `#000000` for light).

| Background Option | Asset URL | Logo Fill Color | Card Background |
| :--- | :--- | :--- | :--- |
| `light` | `https://assets.dub.co/misc/partner-earnings-share-light.jpg` | `#000000` | `bg-white` |
| `dark` | `https://assets.dub.co/misc/partner-earnings-share-dark.jpg` | `#ffffff` | `bg-neutral-900` |

```tsx
const WIDTH = 1368;
const HEIGHT = 994;

const BACKGROUND_IMAGES = {
  light: "https://assets.dub.co/misc/partner-earnings-share-light.jpg",
  dark: "https://assets.dub.co/misc/partner-earnings-share-dark.jpg",
};
…
const logoColor = background === "dark" ? "#ffffff" : "#000000";

return new ImageResponse(
  (
    <div
      tw="flex flex-col w-full h-full items-center justify-center"
      style={{ fontFamily: "Inter Semibold" }}
    >
      <img
        src={BACKGROUND_IMAGES[background]}
        tw="absolute inset-0"
        style={{ width: WIDTH, height: HEIGHT, objectFit: "cover" }}
      />

      <div
        tw={`flex flex-col rounded-3xl shadow-2xl overflow-hidden ${
          background === "dark" ? "bg-neutral-900" : "bg-white"
        }`}
        style={{
          width: 1210,
          marginTop: -10,
        }}
```
Sources: [apps/web/app/api/og/partner-earnings/route.tsx:9-15](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-earnings/route.tsx#L9-L15), [apps/web/app/api/og/partner-earnings/route.tsx:52-73](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-earnings/route.tsx#L52-L73)

> [!NOTE]
> Currency values rendered in the earnings summary header use `currencyFormatter` with `maximumFractionDigits: 2` to standardize payout display figures across variable locales.

### Chart Component and SVG Scaling Constants

The internal `Chart` component receives the timeseries data and background variant. It defines layout constants for circle rendering and coordinate scaling, operating on an internal canvas width of 1400 and height of 600 pixels.

```tsx
function Chart({
  data,
  background,
}: {
  data: { start: string; earnings: number }[];
  background: "light" | "dark";
}) {
  if (!data || data.length === 0) {
    return null;
  }

  const circleRadius = 10;
  const strokeWidth = 6;
  const strokeHalfWidth = strokeWidth / 2;
  const edgePadding = circleRadius + strokeHalfWidth;

  const width = 1400;
  const height = 600;
  const padding = {
```
Sources: [apps/web/app/api/og/partner-earnings/route.tsx:160-178](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/partner-earnings/route.tsx#L160-L178)

## Analytics Timeseries Social Cards

### Edge Route Request Handling and Validation

The analytics Open Graph endpoint (`apps/web/app/api/og/analytics/route.tsx`) processes incoming GET requests by extracting `linkId` and `folderId` query parameters. If both parameters are absent, the route immediately responds with a status code of `400` and the text `"Missing linkId or folderId"`.

```tsx
export async function GET(req: NextRequest) {
  // Load Inter Medium font (weight 500)
  const interMedium = await loadGoogleFont("Inter:wght@500");

  const linkId = req.nextUrl.searchParams.get("linkId");
  const folderId = req.nextUrl.searchParams.get("folderId");

  if (!linkId && !folderId) {
    return new Response("Missing linkId or folderId", {
      status: 400,
    });
  }
```
Sources: [apps/web/app/api/og/analytics/route.tsx:16-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L16-L27)

When `linkId` is supplied, the route queries `prismaEdge.link.findUnique` with dashboard inclusion. It verifies that the record exists and possesses an active public [analytics dashboard](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/analytics-dashboard), returning status `404` if the link is missing or `403` if `dashboard` is null. When `folderId` is provided instead, it queries `prismaEdge.folder.findUnique`, validating its existence and dashboard presence in the same manner.

```tsx
if (linkId) {
  const data = await prismaEdge.link.findUnique({
    where: {
      id: linkId,
    },
    include: {
      dashboard: true,
    },
  });

  if (!data) {
    return new Response("Link not found", {
      status: 404,
    });
  }

  if (!data.dashboard) {
    return new Response("Link does not have a public analytics dashboard", {
      status: 403,
    });
  }
  workspaceId = data.projectId;
  link = {
    domain: data.domain,
    key: data.key,
    url: data.url,
  };
} else if (folderId) {
  const data = await prismaEdge.folder.findUnique({
    where: {
      id: folderId,
    },
    include: {
      dashboard: true,
    },
  });

  if (!data) {
    return new Response("Folder not found", {
      status: 404,
    });
  }

  if (!data.dashboard) {
    return new Response("Folder does not have a public analytics dashboard", {
      status: 403,
    });
  }
  workspaceId = data.projectId;
  folder = {
    id: data.id,
    name: data.name,
  };
}
```
Sources: [apps/web/app/api/og/analytics/route.tsx:32-85](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L32-L85)

### Tinybird Timeseries Data Fetching

Concurrently with font loading via `loadGoogleFont("Inter:wght@500")`, the route calculates date ranges using `getStartEndDates` with a fixed `30d` interval. It queries the [Tinybird analytics](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/tinybird-analytics) pipeline endpoint at `https://api.us-east.tinybird.co/v0/pipes/v3_timeseries.json`.

```tsx
import { formatUTCDateTimeClickhouse } from "@/lib/analytics/utils/format-utc-datetime-clickhouse";
import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates";
import { prismaEdge } from "@/lib/prisma/edge";
import {
  GOOGLE_FAVICON_URL,
  getApexDomain,
  linkConstructor,
  nFormatter,
} from "@dub/utils";
import { Folder, Link } from "@prisma/client";
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
import { loadGoogleFont } from "../load-google-font";
…
const { startDate, endDate, granularity } = getStartEndDates({
  interval: "30d",
});

const timeseriesData = await fetch(
  `https://api.us-east.tinybird.co/v0/pipes/v3_timeseries.json?${new URLSearchParams(
    {
      event: "clicks",
      ...(workspaceId ? { workspaceId } : {}),
      ...(folderId ? { folderId } : {}),
      ...(linkId ? { linkId } : {}),
      start: formatUTCDateTimeClickhouse(startDate),
      end: formatUTCDateTimeClickhouse(endDate),
      granularity,
    },
  )}`,
  {
    headers: {
      Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,
    },
    next: {
      revalidate: 60, // revalidate every minute
    },
  },
)
  .then((res) => res.json())
  .then((res) => res.data)
  .catch(() => []);
```
Sources: [apps/web/app/api/og/analytics/route.tsx:1-13](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L1-L13), [apps/web/app/api/og/analytics/route.tsx:87-114](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L87-L114)

| Query Parameter | Source / Value | Purpose |
| :--- | :--- | :--- |
| `event` | `"clicks"` | Specifies click telemetry event stream |
| `workspaceId` | `data.projectId` | Scopes data to the parent workspace |
| `folderId` | `folderId` (conditional) | Scopes data to a specific folder |
| `linkId` | `linkId` (conditional) | Scopes data to an individual short link |
| `start` | `formatUTCDateTimeClickhouse(startDate)` | Beginning timestamp of the 30-day window |
| `end` | `formatUTCDateTimeClickhouse(endDate)` | Ending timestamp of the 30-day window |
| `granularity` | `granularity` | Time bucket aggregation interval |

```tsx
const timeseriesData = await fetch(
  `https://api.us-east.tinybird.co/v0/pipes/v3_timeseries.json?${new URLSearchParams(
    {
      event: "clicks",
      ...(workspaceId ? { workspaceId } : {}),
      ...(folderId ? { folderId } : {}),
      ...(linkId ? { linkId } : {}),
      start: formatUTCDateTimeClickhouse(startDate),
      end: formatUTCDateTimeClickhouse(endDate),
      granularity,
    },
  )}`,
```
Sources: [apps/web/app/api/og/analytics/route.tsx:91-102](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L91-L102)

> [!WARNING]
> Requests to the Tinybird API authenticate via bearer token using `process.env.TINYBIRD_API_KEY` and enforce a cache revalidation period of 60 seconds (`next: { revalidate: 60 }`). Failures in fetching or parsing JSON catch and fall back to an empty array `[]`.

```tsx
  {
    headers: {
      Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,
    },
    next: {
      revalidate: 60, // revalidate every minute
    },
  },
)
  .then((res) => res.json())
  .then((res) => res.data)
  .catch(() => []);
```
Sources: [apps/web/app/api/og/analytics/route.tsx:103-115](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L103-L115)

### Chart Component and SVG Coordinate Scaling

The internal `Chart` component renders an SVG element inside the `ImageResponse` with a fixed viewport width of `1100` and height of `370`. It computes `maxClicks` across all data points and applies linear scaling functions for coordinate transformation.

```tsx
const Chart = ({ data }) => {
  // Define SVG size
  const width = 1100;
  const height = 370;

  // Find the max clicks value for Y-axis scaling
  const maxClicks = Math.max(...data.map((d) => d.clicks));

  // Function to convert date to X coordinate
  const scaleX = (index) => (width / (data.length - 1)) * index;

  // Function to convert clicks to Y coordinate
  const scaleY = (clicks) => height - (clicks / maxClicks) * height;
```
Sources: [apps/web/app/api/og/analytics/route.tsx:231-244](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L231-L244)

```mermaid
flowchart TD
    A["Raw Timeseries Data"] --> B["Math.max(...data.clicks)"]
    B --> C["scaleX: (width / (length - 1)) * index"]
    B --> D["scaleY: height - (clicks / maxClicks) * height"]
    C --> E["Append Bottom Corners (width,height 0,height)"]
    D --> E
    E --> F["SVG Polyline with Linear Gradient Fill"]
```

Sources: [apps/web/app/api/og/analytics/route.tsx:236-251](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L236-L251), [apps/web/app/api/og/analytics/route.tsx:268-273](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L268-L273)

> [!TIP]
> To form a closed shape suitable for SVG area fill gradients (`customGradient`), the coordinate mapping appends closing points at `width,height` and `0,height` after mapping each data entry's X and Y coordinates.

```tsx
// Extend the points to the bottom to create a closed shape for the fill
let points = data
  .map((d, index) => `${scaleX(index)},${scaleY(d.clicks)}`)
  .join(" ");
// Close the shape by drawing a line to the bottom right corner and bottom left corner
points += ` ${width},${height} 0,${height}`;

…
<defs>
  <linearGradient id="customGradient" x1="0" y1="0" x2="0" y2="1">
    <stop offset="0%" stop-color="currentColor" stop-opacity="0.2" />
    <stop offset="100%" stop-color="currentColor" stop-opacity="0.01" />
  </linearGradient>
</defs>
```
Sources: [apps/web/app/api/og/analytics/route.tsx:245-251](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L245-L251), [apps/web/app/api/og/analytics/route.tsx:261-266](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/analytics/route.tsx#L261-L266)

## Partner Program Card Previews

### Affiliate Program and Group Reward Rendering

The Open Graph endpoint at `apps/web/app/api/og/program/route.tsx` serves dynamic program cards by querying Prisma for a specific program slug and partner group. If the group parameter is omitted, it falls back to `DEFAULT_PARTNER_GROUP.slug`. It extracts branding metadata, including `logo`, `wordmark`, and `brandColor` (defaulting to `#000000`), alongside [reward rules](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/partner-programs/reward-rules) mapped via `serializeReward`.

```tsx
// GET /api/og/program?slug=program&groupSlug=group
export async function GET(req: NextRequest) {
  const slug = req.nextUrl.searchParams.get("slug");
  const groupSlug = req.nextUrl.searchParams.get("groupSlug");

  if (!slug) {
    return new Response("Missing 'slug' parameter", {
      status: 400,
    });
  }
  const [interSemibold, program] = await Promise.all([
    loadGoogleFont("Inter:wght@600"),
    prisma.program.findUnique({
      where: {
        slug,
      },
      include: {
        groups: {
          where: {
            slug: groupSlug ?? DEFAULT_PARTNER_GROUP.slug,
          },
          include: {
            clickReward: true,
            saleReward: true,
            leadReward: true,
            referralReward: true,
          },
        },
      },
    }),
  ]);

  if (!program) {
    return new Response("Program not found", {
      status: 404,
    });
  }

  const group = program.groups[0];

  if (!group) {
    return new Response("Group not found", {
      status: 404,
    });
  }

  const logo = group.wordmark || group.logo;
  const brandColor = group.brandColor || "#000000";

  const rewards = [group.clickReward, group.leadReward, group.saleReward]
    .filter((r): r is Reward => r !== null)
    .map(serializeReward);
  const reward = rewards[0];
```
Sources: [apps/web/app/api/og/program/route.tsx:18-71](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/route.tsx#L18-L71)

| Parameter / Field | Source / Expression | Purpose |
| :--- | :--- | :--- |
| `slug` | `req.nextUrl.searchParams.get("slug")` | Identifies the target affiliate program in Prisma |
| `groupSlug` | `req.nextUrl.searchParams.get("groupSlug")` | Scopes reward rules to a partner group, falling back to `DEFAULT_PARTNER_GROUP.slug` |
| `logo` | `group.wordmark || group.logo` | Selects brand imagery for the card header |
| `brandColor` | `group.brandColor || "#000000"` | Dynamically styles the badge and CTA button |
| `rewards` | `[group.clickReward, group.leadReward, group.saleReward]` | Filters out null rewards and serializes active structures |

```tsx
const slug = req.nextUrl.searchParams.get("slug");
const groupSlug = req.nextUrl.searchParams.get("groupSlug");
…
const group = program.groups[0];

if (!group) {
  return new Response("Group not found", {
    status: 404,
  });
}

const logo = group.wordmark || group.logo;
const brandColor = group.brandColor || "#000000";

const rewards = [group.clickReward, group.leadReward, group.saleReward]
  .filter((r): r is Reward => r !== null)
  .map(serializeReward);
const reward = rewards[0];
```
Sources: [apps/web/app/api/og/program/route.tsx:20-21](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/route.tsx#L20-L21), [apps/web/app/api/og/program/route.tsx:56-70](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/route.tsx#L56-L70)

> [!CAUTION]
> If either the program or the targeted partner group cannot be located in the database, the route immediately terminates by returning a plain `Response` with status `404` and text `'Program not found'` or `'Group not found'`.

```tsx
if (!program) {
  return new Response("Program not found", {
    status: 404,
  });
}

const group = program.groups[0];

if (!group) {
  return new Response("Group not found", {
    status: 404,
  });
}
```
Sources: [apps/web/app/api/og/program/route.tsx:50-62](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/route.tsx#L50-L62)

### Program Marketplace Discovery Categories

The category Open Graph endpoint at `apps/web/app/api/og/program/categories/route.tsx` generates discovery cards for vertical categories using `PROGRAM_CATEGORIES`. It verifies the presence of the `categorySlug` query parameter and performs a case-insensitive match against category identifiers.

```tsx
// GET /api/og/program/categories?categorySlug=ai
export async function GET(req: NextRequest) {
  const categorySlug = req.nextUrl.searchParams.get("categorySlug");

  if (!categorySlug) {
    return new Response("Missing 'categorySlug' parameter", {
      status: 400,
    });
  }

  const category = PROGRAM_CATEGORIES.find(
    ({ id }) => id.toLowerCase() === categorySlug.toLowerCase(),
  );

  if (!category) {
    return new Response("Category not found", {
      status: 404,
    });
  }
```
Sources: [apps/web/app/api/og/program/categories/route.tsx:14-32](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/categories/route.tsx#L14-L32)

| Parameter / Variable | Source / Condition | Purpose |
| :--- | :--- | :--- |
| `categorySlug` | `req.nextUrl.searchParams.get("categorySlug")` | Required query string parameter identifying the category |
| Missing Slug | `!categorySlug` | Returns `400` status with `"Missing 'categorySlug' parameter"` |
| Unknown Category | `!category` | Returns `404` status with `"Category not found"` |
| Font Payload | `loadGoogleFont("Inter:wght@600")` | Loads semibold font weights for ImageResponse rendering |

```tsx
const categorySlug = req.nextUrl.searchParams.get("categorySlug");

if (!categorySlug) {
  return new Response("Missing 'categorySlug' parameter", {
    status: 400,
  });
}

const category = PROGRAM_CATEGORIES.find(
  ({ id }) => id.toLowerCase() === categorySlug.toLowerCase(),
);

if (!category) {
  return new Response("Category not found", {
    status: 404,
  });
}

const interSemibold = await loadGoogleFont("Inter:wght@600");
```
Sources: [apps/web/app/api/og/program/categories/route.tsx:16-34](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/categories/route.tsx#L16-L34)

> [!NOTE]
> Both image generation routes utilize an absolute SVG background layer configured with 20px and 160px grid patterns, linear gradients, and predefined coordinate offsets from the `DARK_CELLS` array `[[2, 3], [5, 3], [56, 7], [53, 1]]` rendered at 2% black opacity.

```tsx
const DARK_CELLS = [
  [2, 3],
  [5, 3],
  [56, 7],
  [53, 1],
];
…
<svg tw="absolute inset-0 text-black/10" width="1200" height="630">
  <defs>
    <pattern
      id="grid"
      width={20}
      height={20}
      patternUnits="userSpaceOnUse"
    >
      <path
        d={`M 20 0 L 0 0 0 20`}
        fill="transparent"
        stroke="currentColor"
        strokeWidth={1}
      />
    </pattern>
    <pattern
      id="grid-large"
      width={160}
      height={160}
      patternUnits="userSpaceOnUse"
    >
      <path
        d={`M 160 0 L 0 0 0 160`}
        fill="transparent"
        stroke="currentColor"
        strokeOpacity={0.5}
        strokeWidth={1}
      />
    </pattern>
    <linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0%" stopColor="#fff" stopOpacity={0} />
      <stop offset="100%" stopColor="#fff" stopOpacity={1} />
    </linearGradient>
  </defs>
  {DARK_CELLS.map(([x, y]) => (
    <rect
      key={`${x}-${y}`}
      x={x * 20 + 1}
      y={y * 20 + 1}
      width={19}
      height={19}
      fill="black"
      fillOpacity={0.02}
    />
  ))}
  <rect fill="url(#grid)" width="1200" height="630" />
  <rect fill="url(#grid-large)" width="1200" height="630" />
  <rect fill="url(#gradient)" width="1200" height="630" />
</svg>
```
Sources: [apps/web/app/api/og/program/categories/route.tsx:7-12](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/categories/route.tsx#L7-L12), [apps/web/app/api/og/program/categories/route.tsx:43-91](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/og/program/categories/route.tsx#L43-L91)

## Link Preview Builder UI

### Interactive Preview Components and State Management

The link preview builder interface relies on client-side state management through `react-hook-form` and dedicated provider contexts to let users modify Open Graph card assets interactively. The core `LinkPreview` component registers multiple social platform tabs including Default, X/Twitter, LinkedIn, and Facebook, each mapping to specialized preview subcomponents that render title, description, and asset domains.

```tsx
const tabs = ["default", "x", "linkedin", "facebook"] as const;
type Tab = (typeof tabs)[number];

const tabTitles: Record<Tab, string> = {
  default: "Default",
  facebook: "Facebook",
  linkedin: "LinkedIn",
  x: "X/Twitter",
};

const tabIcons: Record<Tab, Icon> = {
  default: GlobePointer,
  x: Twitter,
  linkedin: LinkedIn,
  facebook: Facebook,
};

type OGPreviewProps = PropsWithChildren<{
  title: string | null;
  description: string | null;
  hostname: string | null;
  password: string | null;
}>;

const tabComponents: Record<Tab, ComponentType<OGPreviewProps>> = {
  default: DefaultOGPreview,
  x: XOGPreview,
  linkedin: LinkedInOGPreview,
```
Sources: [apps/web/ui/links/link-builder/link-preview.tsx:44-71](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/links/link-builder/link-preview.tsx#L44-L71)

| Tab Key | Component | Icon | Purpose |
| :--- | :--- | :--- | :--- |
| `default` | `DefaultOGPreview` | `GlobePointer` | Standard Open Graph rendering layout |
| `x` | `XOGPreview` | `Twitter` | Platform preview for X (Twitter) cards |
| `linkedin` | `LinkedInOGPreview` | `LinkedIn` | Platform preview for LinkedIn share posts |
| `facebook` | `FacebookOGPreview` | `Facebook` | Platform preview for Facebook link attachments |

```tsx
const tabTitles: Record<Tab, string> = {
  default: "Default",
  facebook: "Facebook",
  linkedin: "LinkedIn",
  x: "X/Twitter",
};

const tabIcons: Record<Tab, Icon> = {
  default: GlobePointer,
  x: Twitter,
  linkedin: LinkedIn,
  facebook: Facebook,
};

type OGPreviewProps = PropsWithChildren<{
  title: string | null;
  description: string | null;
  hostname: string | null;
  password: string | null;
}>;

const tabComponents: Record<Tab, ComponentType<OGPreviewProps>> = {
  default: DefaultOGPreview,
  x: XOGPreview,
  linkedin: LinkedInOGPreview,
```
Sources: [apps/web/ui/links/link-builder/link-preview.tsx:47-71](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/links/link-builder/link-preview.tsx#L47-L71)

> [!NOTE]
> Whenever a user modifies the metadata title or description inside any preview card tab on a non-free workspace plan, the form handler automatically updates the `proxy` boolean flag to `true`, ensuring custom overrides take precedence over the destination URL's native metatags.

```tsx
if (plan && plan !== "free") {
  setValue("proxy", true, { shouldDirty: true });
}
…
if (plan && plan !== "free") {
  setValue("proxy", true, { shouldDirty: true });
}
…
if (plan && plan !== "free") {
  setValue("proxy", true, { shouldDirty: true });
}
…
if (plan && plan !== "free") {
  setValue("proxy", true, { shouldDirty: true });
}
```
Sources: [apps/web/ui/links/link-builder/link-preview.tsx:310-312](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/links/link-builder/link-preview.tsx#L310-L312), [apps/web/ui/links/link-builder/link-preview.tsx:323-325](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/links/link-builder/link-preview.tsx#L323-L325), [apps/web/ui/links/link-builder/link-preview.tsx:357-359](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/links/link-builder/link-preview.tsx#L357-L359), [apps/web/ui/links/link-builder/link-preview.tsx:370-372](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/links/link-builder/link-preview.tsx#L370-L372)

## Related

- [Link Dispatch](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-attribution/link-dispatch)
- [Partner Programs](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/partner-programs/partner-programs)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/llms.txt) for all pages in this wiki.
