Skip to main content
doc0blade47/dubdocumentation home
Sign in

Dynamic Card Previews

Last updated Oct 12, 2026100% grounded
Relevant source files

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

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, apps/web/app/api/og/partner-earnings/route.tsx:5-10, apps/web/ui/links/link-builder/link-preview.tsx:61-75

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.

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

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.

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

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.

{/* 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

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.

    </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

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).

export const isStored = (url: string) => {
  return url.startsWith(R2_URL) || url.startsWith(OG_AVATAR_URL);
};

Sources: apps/web/lib/storage.ts:414-416

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.

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

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.

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

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.

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

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.

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

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.

  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

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".

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

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

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

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.

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

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 OptionAsset URLLogo Fill ColorCard Background
lighthttps://assets.dub.co/misc/partner-earnings-share-light.jpg#000000bg-white
darkhttps://assets.dub.co/misc/partner-earnings-share-dark.jpg#ffffffbg-neutral-900
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, apps/web/app/api/og/partner-earnings/route.tsx:52-73

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.

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

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".

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

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, 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.

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

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 pipeline endpoint at https://api.us-east.tinybird.co/v0/pipes/v3_timeseries.json.

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, apps/web/app/api/og/analytics/route.tsx:87-114

Query ParameterSource / ValuePurpose
event"clicks"Specifies click telemetry event stream
workspaceIddata.projectIdScopes data to the parent workspace
folderIdfolderId (conditional)Scopes data to a specific folder
linkIdlinkId (conditional)Scopes data to an individual short link
startformatUTCDateTimeClickhouse(startDate)Beginning timestamp of the 30-day window
endformatUTCDateTimeClickhouse(endDate)Ending timestamp of the 30-day window
granularitygranularityTime bucket aggregation interval
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

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 [].

  {
    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

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.

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

Sources: apps/web/app/api/og/analytics/route.tsx:236-251, apps/web/app/api/og/analytics/route.tsx:268-273

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.

// 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, apps/web/app/api/og/analytics/route.tsx:261-266

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 mapped via serializeReward.

// 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

Parameter / FieldSource / ExpressionPurpose
slugreq.nextUrl.searchParams.get("slug")Identifies the target affiliate program in Prisma
groupSlugreq.nextUrl.searchParams.get("groupSlug")Scopes reward rules to a partner group, falling back to DEFAULT_PARTNER_GROUP.slug
logo`group.wordmark
brandColor`group.brandColor
rewards[group.clickReward, group.leadReward, group.saleReward]Filters out null rewards and serializes active structures
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, apps/web/app/api/og/program/route.tsx:56-70

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'.

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

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.

// 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

Parameter / VariableSource / ConditionPurpose
categorySlugreq.nextUrl.searchParams.get("categorySlug")Required query string parameter identifying the category
Missing Slug!categorySlugReturns 400 status with "Missing 'categorySlug' parameter"
Unknown Category!categoryReturns 404 status with "Category not found"
Font PayloadloadGoogleFont("Inter:wght@600")Loads semibold font weights for ImageResponse rendering
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

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.

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, apps/web/app/api/og/program/categories/route.tsx:43-91

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.

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

Tab KeyComponentIconPurpose
defaultDefaultOGPreviewGlobePointerStandard Open Graph rendering layout
xXOGPreviewTwitterPlatform preview for X (Twitter) cards
linkedinLinkedInOGPreviewLinkedInPlatform preview for LinkedIn share posts
facebookFacebookOGPreviewFacebookPlatform preview for Facebook link attachments
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

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.

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, apps/web/ui/links/link-builder/link-preview.tsx:323-325, apps/web/ui/links/link-builder/link-preview.tsx:357-359, apps/web/ui/links/link-builder/link-preview.tsx:370-372

Was this page helpful?